From 3bec5d7e7c6c171eae651931b8eee5e5566284b4 Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Tue, 29 Sep 2026 21:43:46 +0100 Subject: [PATCH 01/24] Initial commit Generated by create-expo 5.0.3. --- apps/mobile/.claude/settings.json | 5 +++ apps/mobile/.gitignore | 41 ++++++++++++++++++ apps/mobile/AGENTS.md | 41 ++++++++++++++++++ apps/mobile/App.tsx | 20 +++++++++ apps/mobile/LICENSE | 21 +++++++++ apps/mobile/app.json | 25 +++++++++++ .../mobile/assets/android-icon-background.png | Bin 0 -> 17549 bytes .../mobile/assets/android-icon-foreground.png | Bin 0 -> 78796 bytes .../mobile/assets/android-icon-monochrome.png | Bin 0 -> 4140 bytes apps/mobile/assets/favicon.png | Bin 0 -> 1129 bytes apps/mobile/assets/icon.png | Bin 0 -> 393493 bytes apps/mobile/assets/splash-icon.png | Bin 0 -> 17547 bytes apps/mobile/index.ts | 8 ++++ apps/mobile/package.json | 22 ++++++++++ apps/mobile/tsconfig.json | 6 +++ 15 files changed, 189 insertions(+) create mode 100644 apps/mobile/.claude/settings.json create mode 100644 apps/mobile/.gitignore create mode 100644 apps/mobile/AGENTS.md create mode 100644 apps/mobile/App.tsx create mode 100644 apps/mobile/LICENSE create mode 100644 apps/mobile/app.json create mode 100644 apps/mobile/assets/android-icon-background.png create mode 100644 apps/mobile/assets/android-icon-foreground.png create mode 100644 apps/mobile/assets/android-icon-monochrome.png create mode 100644 apps/mobile/assets/favicon.png create mode 100644 apps/mobile/assets/icon.png create mode 100644 apps/mobile/assets/splash-icon.png create mode 100644 apps/mobile/index.ts create mode 100644 apps/mobile/package.json create mode 100644 apps/mobile/tsconfig.json diff --git a/apps/mobile/.claude/settings.json b/apps/mobile/.claude/settings.json new file mode 100644 index 0000000..176e6a5 --- /dev/null +++ b/apps/mobile/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "expo@claude-plugins-official": true + } +} diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000..d914c32 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,41 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md new file mode 100644 index 0000000..be05e77 --- /dev/null +++ b/apps/mobile/AGENTS.md @@ -0,0 +1,41 @@ +This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility. + +## Expo has changed — do not trust your training data + +Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API: + +1. Read the major version of the `expo` package in `package.json`. +2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v.0.0/` +3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory. + +## Commands + +Use `bunx` instead of `npx` if the project uses bun (`bun.lock` present). + +```bash +npx expo install # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions +npx expo start # start the dev server +npx expo lint # lint +npx tsc --noEmit # typecheck +npx expo-doctor # diagnose dependency and config issues +npx expo install --fix # fix incompatible package versions +``` + +Run lint and typecheck before declaring any task done. + +## Navigation & Routing + +- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`. +- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`. +- Docs: https://docs.expo.dev/router/introduction.md + +## Building with EAS + +Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `bunx eas-cli ` in Bun projects, or `npx eas-cli@latest ` otherwise; substitute that for bare `eas` in docs examples. +Docs: https://docs.expo.dev/eas/index.md + +## Rules + +- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins. +- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`. +- Prefer recommended Expo modules over third-party libraries, and check your available skills before adding dependencies. Docs: https://docs.expo.dev/versions/latest/index.md diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx new file mode 100644 index 0000000..0329d0c --- /dev/null +++ b/apps/mobile/App.tsx @@ -0,0 +1,20 @@ +import { StatusBar } from 'expo-status-bar'; +import { StyleSheet, Text, View } from 'react-native'; + +export default function App() { + return ( + + Open up App.tsx to start working on your app! + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/apps/mobile/LICENSE b/apps/mobile/LICENSE new file mode 100644 index 0000000..30b20e3 --- /dev/null +++ b/apps/mobile/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 0000000..f3d12d1 --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,25 @@ +{ + "expo": { + "name": "TrueTunes-Android", + "slug": "TrueTunes-Android", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "backgroundColor": "#E6F4FE", + "foregroundImage": "./assets/android-icon-foreground.png", + "backgroundImage": "./assets/android-icon-background.png", + "monochromeImage": "./assets/android-icon-monochrome.png" + }, + "predictiveBackGestureEnabled": false + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/apps/mobile/assets/android-icon-background.png b/apps/mobile/assets/android-icon-background.png new file mode 100644 index 0000000000000000000000000000000000000000..5ffefc5bb57a3d7b39ec6ff4e96979226522cc49 GIT binary patch literal 17549 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

f!0B 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/android-icon-monochrome.png b/apps/mobile/assets/android-icon-monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..77484ebdbca253297baea4a7d416233aa47a45c0 GIT binary patch literal 4140 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 literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/favicon.png b/apps/mobile/assets/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..408bd746615785760b09e6f23fa8378d3e614331 GIT binary patch literal 1129 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
    H|zz{j8vX-lqku;-NW2E3;BMf3f>Jhxt3 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
    %-(a@!i`89VK;?-qg=ZiR0A&pVk z*QwtA8I#3~E1D=exysS-y56KT%{E?RRZ(Jq;%g)@k&s}@ z180^o?#h`nyPa6g#d4UFm;dhMST`Rlz?pmKJt=a&>=A-|_uk&bjMCaUp%!4s@vcU(XnAK?zAvAO^?EhSV=vWop!7#} zsI7Kb_k{_biZG%ioR-#K?mKMC7h33?Jm7#aQ(mUkFMg)UqXG%^lxH`u<+jCG*XUp1 z7}$wyJn8Dp=%U5amvo`3IfR(uI609dIGH+)70}TDr(<8`*@sztA$sHr~>7N zk$`_P8J#_eT)BAcMs2puzcQCpRs_lDUmxc~S_QBCKHUUMil6_E@tX2{DRgh5rkXc+ z;%O%Z$FrcIVT}l=3<=NAHj}g0U$(*b5*ku^@dBpl!rq2YYxrgJh-U(k5-_RUOc>6t zSoET%T;1sIx2+#SS?kak&gKy{g02-kD_~FV6u5d3`n)ehB4TjBzoW1ew}|_@9p9t2 zVUNh;(w`nUcPEHX&Lt0^vDux&F`b+*isA>UFn-bEoI}%zl#zzM#Qeo{B4;HJ{N##& z#t|xtwdpQ3tSf1*D9RQCHs3EPMMLB}w8M8K2x6)<#?Ms9i7OEIAleu<+VKTHOU@9& zc*?Wuupi$m_0j3MIwn=zSW=LR0phSIao~ZqKQ~{P)h{w;upc26E}qwx{6RQ0Un3%oJdpD(BQ7NP54Z+x2sV4_Z*wBs zLjiwzHzJnb8}veW=f2 z`mH0i#Lz@7$+}Hn4>9HYEiKXBOaaA0C+srSel-pdJy{@*Y6S|h|k!UN$N*kP)=0g^B@Ql<;P zX-$wC*d-KImCL1b{6Un8B+341!L;%3UxoPOrn&|}CNYPw``hCvF07<#Keh>&fdSu>Mq+3`3n?0Db) zcUfgkH7frFsjw5d+T$B3Zq78$zrvfkgky-J0s~oyVt#4OM@JEn`LudJ?LWgNS>kB! zKF%KPZMeNWjv^mh&q<)>A@i6B?X(Ch%@*IiJo43wa8I1Cy~;{0>$Vf}$Hm>-PTj*6 z(n^-zy!FkYCt8FyXX6(nOw|V&2qR$pzAQoY+Z_vQXd)?SDQ|DGCg{Z1B%1<$>RL1B zD!+-|!KVCc??Nc-W4$-;I1DY#S_8DK6l(YR=w}{I$7X?-adMrNgcGp~RJRGOI{)p5 zq@dH^tG)p?6EhSn`U?W3 zXk00=2E)h`$NIx&k(Y%vleQT&7)P}JpZfTK3B48gpj8;33P%vM(U}C_U*Wg@&$K|WD`D-hki4i1vZ zR*8F!a&XFWOD%C>#Yn{RGU34`*=){?=LOwCCx=#b0a1iCeLp>1oiby4`Wl)-mxYVPYGlsD!@&_VqgH3? zypIy&C(l#NQqD)=Sw_8XC#-NUY<;&x*{k8G=XFCDk&$FNbe3&+Gh+RJ1v+fP(!BfI z#@jRp7T+G%bdPP1!x!OqR;~(bkffJS{nOJhw^Ut+Ry#Z-17Z7UtDQNUkG%&AvX+J$ z1M5e9lQ}97_D9@1*Tyh+=!fZGSH)joyc!W&;}GQxCY1GBN6MZ5C(msmQa_va{h(kM zSVPik8J*~q_vv}Sz3BZLy@;oeFJ+#C8w4|D#iIL_C)wH>^h9Yy zC<{GyEL__Fy1|90ko5KbP;d<54oj8dS670=%;ehY!~|byg3zJ3h zMjr@mBVv@{h1McjghUu%zoK_ampx6Xh(YDhF7Y&}Sd#Xa!%q77I&ud|>cd6tFvqDy z;8yX+cONar;S!$rzdL8cZB@vBV_L0yhC=PAj1VbJU|voe(351$GDTsQlJ>K*(Z{!E zZ%b`jzCfn5X|WA>0-ty0=ymJ)WU=tfKx|ng&`UK6n}isBRip=R{Ebshgf|n~iD%)T ziHhT}!-;VA`%(R2;eUU+^+)hxIHvGRxZK|?R*IS!@EuclW4}54!rF-q|6L_`SPQ^+ z^mG#d3Ah60B<#f$C%((nWKU&T>%>CXU}{{WP^;3mVBUBhCgz)`i zUOt(cGLWRddTpKX@C;O^Ur(LsLQZxmUnVjf0U8~GEe5;Rt)KI(ST%n9d)Vp{m!~!* zkKnp+$mx3G`eT+sik@(a@e>w&7=?H*>6cOdJR;3ZI$0%}p}?-h#?GsD%t}JxE>%cQ z0j~q_3W&N2+dn+H{#oWV*Va`11>KQq>IUA^W*@YCM)~x^geXAx#9p~?Zr6$4g3`+P~}E)lPBF zp{9r=2W7Yft00eOmUwyB#oByH-$$_&3 z{UM68Km5}@>X(7Z3m1;tm<1;?b3?A5{^zpSk87g8d;hGhdBnWk=bOd0>0G%hN*I%R zqGW$e|Han4Zki?i9Y4Nw=*Gj3yIxtZGbam6IRG9KIMpu2&|+MJ$S0{N;XlgdvW-q7 z{KV8)2YxgPbvE%kjw0?%$0+jMPQTab$JyDT>Ly#%+IP%539I~GRwxKikNiw21>MCT zY6jTn3$wS_x_e~Lw-<8!cVKa{PQp+Gl&@I2PHVD+SVR)SUc2rf4g`5^9(q!yhcNO$ zO5{I0MMAHyri;M=qcz;v)wQI|OmY7d&$~kD=fxrSpPOB7*$H&f$^JTfX(OU|sq%ZB zI%E>D4ux$FjmAZF17&zi)KjTRulo7reP1iEwfw;{o;b2b`ba>(Fb5>^c6%0MUnAvO>AC#T13}B3;q8E7Qb&&nB*^2WT1p?!}VG3 ztGSZDOMl?=>?Xa_cVQzlh$#17$fkoKSmr_tm}Y%`2^e z(6Kz->!$Dijutl@(Xicm=}li88toLDks%Y6f+3Y$tUN|8{*VmIVBMxrd)IUjr2 zzae6w@E9ulam&$~P zR&xKqqv0h{Z0}$-X(~|H%o=s&5!vH^Y(d*w4z%N*2{%@X$gn<6IMG(#hwa0kf+@-j zhcoDszRz73UM2>lAgT>zP3_5L>!$vZUs3O9lkb}j#aUnb#sv>n)HBvHt2hT`#Y%wM zI~x)PpO>qs0vu->9TY0py{3DM_c|vBT0Z*~pkxDx%h>$L=Iq~m38RcZ97;xEm#Y&UEg)rrz ze|mj1WcuT&&ck7TLjP9He0}*ET7d9rt!h)7oF%Oq1TyLyxrCCv@~6==sN0;!MY(TF z+xzipirHEMD^fg`GqUjmk8~~we2f!b!@AYcWY2~*LT~X9V@~dtQ#HHetzfVe^c*Yw z$L@Rw;qmI>nNty4i{$yHnIIl9#Zlc57#EtSE~$`33!nY;ZUpUMx3L zhVki0Yx73AfTI`T%=IL2Q|sDSf?$CeLH*(FK~n~wK@^*6cwDr;IP$p-Vx6xRJdqm( zZHYzSqn&q^naqj}-tdf|n7T{!)0Ea1J@Us>o*v2fA%C8|4h{H}ldym1fl5?VCaHKa zbp2hH#2SU(lk;#6P;mSh?8RkvYN=`gs{s~yz_m{4q;eB!uNk9H`^bNtFqUM!XPrI?;$FxlPJ!xJ07dL#N_Uo_X|XBvyGW&j zMA^~IU3a(QPZB)*sw^A5ZuEJAD4+fw{(Quh{fHI!3*;}7UzpQ2Fwt+p)qkH{{5Tjj z(-4Wz$>rlL)jzy<^~lbiZOppi@zGfEVm34GRy3e0GZ9Favp+N9_uh5iCQ@nk&WaX( zV41Z!LG&gw^B)-^aJ7{JguD%h8)>7y$JtVtbWmKq*_-H%l92*;Dr<#EIq>~axJ$-# znlt0)!WOT(__ni|nWmccK7`1f33-(YT&D%!Jk9va`rMh)>|jgi%@wACqS)!Zsw@sf z1jHj5L_q&FNq4q`jx2QQZ3w>x>z_IAlg_*D@i`in=2^wudo;0zs#1pKRCjCw4Z|NE zgU+I&zrOFWqMe*Cj;jLM0rKe#TBRI`Oi&8-uiRDIs6!_`32=6n<9lLC1v`_|eVc3; zk-jBZ!Sowa?sKb{P&6oR&#wbrP)Zql)U$j0h$(PNhOmTQeJi*RrV77xar*^bnR*%Cgjs>$nfe-Q1Rxat{U$agiRr|xSGHR z{+&Ch=|^(tlU9$#u`3a6`sLz>MvaV+^sYcFEJQSU=VaBM_fu7wehDtkNzCDYXbhp^ zgVqOd0{n@Z7-EGb1p*~S7#l}0vmuZyBxQo1O9|f5VKXgl{`ACI!>ypS^!IgF^?5R% zYUXYN ztEo#1^rm9+V)NTC)DZ#LwM)RIaU3Y$sw~V0zRLd0V*yEU{~4-AzIGmyo=AwV7j<_* z+;1YRo>He-%!_|o&PN{5=;`)G2uTy=!)L<~jIaXlky_v1;Sc`ppF9^{4Ey+U?c3r1 zWBHL0D9rZLgFyVf%{1m?oLELySrn9(9EX2Q5i2V1Uo$Y4iy3<LN(Ka6?3i^rL234$qUv=;kZ`_NTu_(PG?F|BtZtBeC<*px=4`yF2@0p?n z$0aU<;@Li`s}J%5BTp8h>;w2D+gYNtJEquE2$9I~j#ezl)oIV|1Qc9Sz$wx%%rOd4 z@dc5WMY;#L5!5=bjqHq@19~Fd2XDO@kjC4oIdzv!RB-JH7a;rrLf6&PRb75JNnG~2 z17PZfC~*EEyzFLoTsC|cLrAPNGpQd}%V*`p&V3)4sXFTbWLP%wdc_p4JLPIV6wYVW zqONa#luezoSRB7c5#%8GRq)wH8UA;@KZ)>abBYEAm)Ns|t_>SmiU;5eH-B8GBL)b2 zOu)iG4rEJtnFU7^L2YsxVK`E7(a~`7md6dAes021ruw}NT7xlrrR&hHKWBci6z_H7lX&z(2CL&B7Np&T^hP4<0llE9cWbq*kBXm)tAE? zr0yzEXxFn|RlN!S#V#5pjnwGa52p9AUUbrt&nl-|87R*NAXMa7b1Zt-I1yZ^jtRLM zh^YI-PiRX<9yC3~ktJ3zMKJ;1JXKR`iuMI_*CJZ03?chIJt-kZQ_KaTTAQ3 zMe1)!ZdA<%{trVZv(n|MZ!+_O)a~#uj=Ws|QxHIp5)wBv9Uy)v7vC&Z=XgOF${{l6 zlWF-&M_ZpJzh95-Mu~ghrcSeGqi|As!yAPVu1m%0*Z$*ti8q6OYZcV7{yu)m2(|#J z1MuK_qMACNkquOO27Fsy7Kg2S)SE6x5&oIgqN^okmc<=t;#r2@xK8(vi zGkf;;dQoS_L93t3hl#djMOGId(cy)!nY(>-nWxS=j9qVw%#1f z%Kx+h^VTPNMcH|L;La?;xh}H*@8@?sW(Q}*gzF;}WCAH5NZ1f!f&@LM^BTdP<>&R05_1ofP>_>G$^_j~v$5Z|(4C&PpX6P-M{+(_!pE|AS(DJdq)#?l zX${HFK@ncOt#IFcX;3Cwf-qnxm_&Gb{BrAIoo~i?AJ=~f;Pe8uylL37W>P~62v?-G zS6geJd`6CQLjBZ^W+WH=$l9`t*q65=oPSU+$&x>&Rnqq8ecS6w<2UNqN9SQD=@zQY z$1H$7(;Q@|`L9f7jzPJy4ob|FY;r_82@F z8vQm75><<`3bWgK>517m9Gg=q3A8Klf2>XJM_xE&wvTmhY^}&FW*57;XwmC2%U(l~ z9lC0t7PIQ1kA&+M=PcIZ7p*2@`))e+|5Vz0kj=GyR<%)}1Gjr3Q>YMb@%Lgxr8EEi82Lz9O1qolk{phMImq)>2RV z6pGcA)qQe0zIo+6S7bsS@LM;oL_PjDL2`ixf9C?;)AorQLJ2jF{Jd-H941?3Wx_g~ zj2exMODL*Vp?(mKD&50Uf3|Mlf)Ly-XOO77Ic+9;LI)*SJyHmz8j8%b+UySt99`t$ zPovVwd%pWwGYr=aDx=I%%9Jm(Xa|pcVtL%(!d|y+A3ifXw#o)Qk2fA#Hmun?G;p9W zBua67S^7kb=_zwbh!c<2LZQx2}W(o%VSryG7$+8{{V+e^Y|dFf<@O5g`GOipZEA z7wu+e(jmu)(*x!^jjgvgc$-3y&|fg%)5A`6T2YGJaxhsTHol*6GuhvDiP+4A%mqVE zLg%9F>lj~cubnIgD<=0b@7O7c`EJb&!u`_4JP=R$QAZFucaeehP5;yXS?E6=_qJ{@ z0XI3N8W#lx*PM<;!uE&bvG?8BsMv4y0(?vEHf~igpyB{cRLVrn2C?Rx>SoxDZ%=@* zJv@hX?DhW1xLD9B8Kcnx)0#-8BdWX#Cbl$*012pyDU#a*50!4lgQ<+E@pVV0VB5_^pbR#gG-E*^Db@ z@&BBfhKk0wr8G~H!lj@`r{I`#y2eJD1o*-_>uB|r!_3R-UOl>{Uf)!ytCx$esGj|Seb%SU^8PeSY%Gi)|egZr2J!x^mL z-+dO8fW9p&H7yQI_h4}x+D3j_FP$qUNPvIlKCIX~ArUBVG8{e0b(}1~=X6)B;1h=WDp(D^p-LY~ zPf1UC%58Y!0KI&c-KOBGf(cM?xlGL^{aA^U!iuBB=fvmMtOq36MI)VyyuPy*37dSx zO(JXcyFMD@yCJ2n-9;5& zU|4qDs(L2&N9J!`>>0d7IFTW>_QT_AoGIL0MURpUf}8o$6k{f!aqBJBNw!UrY(#Nh zj^J-3nVw3~=K2K&$r7e=FJs}FhcV{RRQU9qsvNao&`_RSob;O$)=D=p-ELFqYnxS1 zyog%pGM|2w?VK|_b+vqBSma5~ybFGV6M}GhYIVx(rcS%EQiHGjOUlK@vFme1)|WNa zLf8WAgTOp!CuETM58C!q>wSQ&No+GNx&f@ZL(LOix#ISv8t|_-ekrdhB6VDB>OEb zARqLRd~L^)#LDx#+U%rF9+7!$>D2CiY)Xlf_c26d?>2Y4V67N9afNaw>}94!t+Ji=%-y)8`~VU(G}^M5kYXebVrdwmBkm!c-*3R+YPHGjPdO~keh9C9UJRw%yn&1C1bxQ1-TQFrpi>PC zGy$CEY;v5c$hi6F0-MoTKbpch+iI4WxVQJCa<||Hced0=}#+MeK8qt!B4UcR{ZfkxwKUjxH#^rIC zsX8Vp<-URNnt6IKL;f2it&l}vO_{=t;_gJ$*+~mPB^{X_{s_0(5n=Z=u=D`xaT=p; ziyKXI;5fLfq+N^HRda8|H@rkChaHUy2B+_3vQbd@Wb=!Qt7Ev%ehSIS`!U5jOOygn z-@N+0RR>*GzK{J}BJifjg?w!=h4dG0pCfi08!1Od##xpaOMYCxWT*XJOxhR;acM>Y z&F9%e_SfaRHJVw8TuPy>1bW`r3bk*gKqXe>-Tq{&ZqRe%!yXaI4IWhNO!kks?OFak z9RmgjOo(qss0XnEs0T^Tu&shNXL!S={g&_vQGH9fTm2mmQqj6o0m`7porZC%>x)>* z$?jRPSD{fVb!WM4WlOj8OcF3kI_z6BZ{Df9s!k3l(%D6PsOAC#&J|U;#F=43Lh~Iz zHe|4uk07D})#gjJYhO2f7?!_oq1cvT=E}t$fJadMycfNYxt~+_MW_!Ff+L(_G4#p? zw{TK!IhQ1^`5bLhe}H(wZ6M4&K5?eM$cv1|YL(qCgLQ35m@8dBdX>qkR1sTyL zfS+@&>Q=-6A1OO#rNhWZ5IKQna&y4|G);Stp711{c z-_DPVX6+9hsnDk@UKm0sY8#!mP0eN zNh%JBuAo^~27rN(!=`}Uu_2G64Z=feLIrqef29TN<`mn2->7h7sOuF)2z*3vhC}?a zhq5~`O5L`&9Ms%MUYy5CxQ;!4y56_)+1BSp%liaffo)lAdbs}uZD@1v%qI#I0ti#M zy>G7gL4ifZ1P0>Tezd02>z!IAZjcPcXA)n|d^00;iT_K~g-m-&137t7L=CT?l+VM; z9v?o6f)lbY53(6}o{;Tba~t1iBlj(;63tp$5g`}`#x*EH2mUzk)CIU4b$%%Uhj%gA z25w!~?0vz;O>vmFt`iF0DS>_`Sf&kU5l{ympg{h~fbnqwDvS`i2OmP+!s)o5kA=}O zP$m=>=Kx|c*zuU$P#@0}^S2fkv-{q6!~Li^;$Q}GRZ1Ik6YArw3m4eukpS|px4R&y z@OO3Lr<3oeG~5*LmI26gJV|_tp?NueA|(=XU-WFKj6&4lTaSDXnryY@cXug3;c6Nk zz@|uQx*jA-l%yPNn%-T)TV59@QcT{X>(R-%8}Fhz~M zd2Z0x)((qLx729B|3nyuM;|;&JveK2v+6EINv^3KH!BG@dIfd)SdHq@MNlt8zGh5r0Rba6 zhCTFGW;a!#D*x;>&e(y*n69GcOTtddt9}D>)~5e?84NP0ZO+Cn-$Q&xu)zG{pT7_2 zMo75$A+$d|t@3UX9_o-+zN(W7_}Ez9Alj*gGHF3nX-ab2MXxTlK`(>GJksh3Ik*Wu zl|;WHDrgXB)7N*OcOOoKC=VBs`;CQb!L5uvtUMZE6uAY4rDB?}tfhO#RXq@A7({$4 z3f#~HwwRO=7gt_3>bKVA6>az28|&MjPpecCKBlq7GD21OZ1`$KU3W{dQUKs1^uIlP z73CVrF=%BJVq?DvSF}L~g#$Km!^L_L1V6Y_<>ma+AzTLIe^sc~(1>~%O=E9yzqz|L zZoK;&o~l_!C<7Fyfj-5p7e1mQ#-%bhNeYKt&%N6st9QRNUBwpII_+=#sK z-gr7l8~Z5(udHz^kwe55!Udz>@Ea+Ape? zHCbIYcE9HCA_Rm-Iu~%=USoP-G z{D^UN4hU4UN`oXy%o8=d>+?%HbIM4U>G~vndARWRRiyO=v2(z|7IhF&s*Cd9k@?_2 zmxZ!;g2;%Uy@F_MB#_i49VA+6;u0#>+FRRbd%mT1wx7>6eh|Mxv5ALZbD4-CF!vpE1Q6U7v6IZY;mAYjCBIp@z~GQ%3^j1|Eo4uYsdM zF9^gJ&24Qh=R!M1sGxdbI;F^Ok|8#5P$gBbF{jFEU7^oP6NqH%;=&z0bivB!f>fD$ zq%99qXlJj?wLSa#&lo})^r$4zskjnwvjR%tmz4t2M6lOV_(LU6SkXT z>UR&o8%QD=zh(5}^r@Puv^}D?AO>a0B3TiMhB3VfKt!~7t4himS zsPfPJ6N~mOcaYx30@@$RTH3z{Cl~$obA)AG0#}13Pm{fHoxVkp=`2U{G$7K>C4<65 z_hNY)2Kf&743uo+TCu>hWE11I=UIfe7K1tQ>!2A>6b5HfUY-J|l^Pp9B5DK&CU7f8 zinvOyuv`7I+%T+SBD)+zsPX!I1(vpCvJ$saEA;kwHvEz4R(}vq$eHeWAwX^%LdwO8 zR)vR`*M(qc)+J-hq@{PTrP#j{QJDqH%x<}#Q`AENy>MNTo=Lb_`QDTlPFM|-Ai$v zj^)faU@1^<5+vHR>hHM~93EY2N@RCQ5a{d(N{I$V3*};b2Dky#8m!*AE2u!WcQ*oc zZ(lVyPKbKPJALCe87soEJ{B@y4oEvNpG6KDm^i=83hba;s&7JIR4UL^&lQ1&|F^${2 zZF$%jk9_IzJEc3eM^*Iuh@6336O!n&Js)WP;6FVO1;Sblg3im>Pfz|x<$dYiRzL~E zS)9Xanp4MtNo?0_)Q@)&FF#BW_1B&(j4fApJZpwS_Z$tMpw+bp4#i_@Tx+LFSe z*UE7ooxaAZL*RRUZvCg$wnu@mZG_{0h>S^>^OQqHC5kKT!?V10P09c8v(5=sGx z5kDid8wnf9{b*Fno8!g*L-8qdQf_$Yclpor9HBS7L{F>ijSY?EUB?>R<#Plebw0}pI@o>Hzo4Og`$Q&RZdo)cV$n` zX$8?-e6HH?bSWWD88NAub+%=4u_c%#)@2ohsP;sktq3&)^bP7p$=h8YyaKNl+nv#q0_b*bK)yS7YunTU9p zL%-%w&(Qjc+b>|~W;&L42H!IbG}IhR70^86i!h)`9; z&VL;tpG^k7aC$QPH2y3$TnW9f7%$CfCVRDIUXE5nzBD22CHX8tFzc*U61(bpW_JZZQ(uNBT8*WdF7h8`9*iJ$fgy1O*IG3} zMEJ3V;OcxhR@NMf5A#(H>^Lcrl*PNK27& zQv!d^_5ZCIcXW8idlmdpMvt`C?9K%rj6|HqNhIWxEUosKJNObI98@U=5kr~ipZAC< zs!tBgYh0zP`EZsaiB)BBx4F6PWRc>OQW$WcLfr_D^h#q4wQaA+UB^Tym zhsLE$!wW=&`v*{Lw_bc^6_4>d7L)oSXF+ymXpwSp=Q(p?kJq}mEKxF*d~1Q% z$KhLgY$UtfcyFhqfw-N5&U_o>FRPhB5A^MACjBxIwa5ww=y`|?jI@#Vpqa!Ga}cT3 zciHXz#cP3x8bm_~0HgZP?nUuP>wGNWcoieTSz6%GjNnN0(@2}{ZPq*Mq(+NerXLy8 z+7#*b2$QX*L`)Vn0zJDXPZ#z<*Oe^~hw|luG;zu-VH6mjI_c{@MVqg4<|H=gX9Axd zV(8Pb;=(61Sk?E~b^QRh)EBoQf+L+TfP~=Ve-)Yp)p?)JXCDn9B7%yKtkl4$vBWmv zq?<7r$*Pr(oan)H3N_%9h&7=gJCKSRW7z7>OS3KI>-7B}`lAB~h&YkL4a|{Yd;}ix z1p3^s%6w$&Ou~#g-~2XvX2kLd-XoG4Rmv0txdbDaV}zEd29kU8*qC4~1t5c$+;ba@ z2s-JW5L+6X2X9ukc{^`){!#0ijzSd+e0<^%bdZ&!pd+V=MsYk@e_eBq2Hlhm>+)%o zs>JFMqT=Op+5vNk6reXm&+9Q$yD=w_l`h@XXt_;UO==oeZ-wA%)* z0rsr5XUf7G?6K)MeIE19la{i(M7 zZxOjL&SDhCX{!hfq+C0W}<0^atX8F^Oq{s`LCEO+wu9 zKdh1KbWUUVmp`Cr=Nk&NQ&MeEhKRHIvnkcxRk~4Yy?}yz2=HP{`|cp4C>{@lM9Vm4 zD`K|THa`ydr}cF@xWMaxn>Sp|*O=i!;l){fbln>3p<~m=mJsoA@wm^_{S+DriNw9v zp!k>+T&nMoL%L`&hjpk8|8Vyn1z-@MBp`PUsM{s#rpj9H|O@5k0o)^YgN#e~X zZ}WZWokrk}JNHa5UguK|FD3CBEHqZIKm7SiGfj z(CL1M;jH97j+VR|A9ffasCv6Jx8kTY5|tnugd@TlF*scIWDlECec4><#tq4h4dIX3 z*fGe+UM5ri;p)neoZaGoxM?>y0~RlwFAH^kN{&OsMMjtZSD>OG^%GQ^MN}`;4{f94 z25C5NLKviQVxcBMwE-Rh^u49>Df?Jf+Y`gkn}6jU3h~+= zd#R9wb_sylR*bx?5P%&;V>w{JoAV;_pDP)6g_0p!4#oljPnl@5c40ygT#Crji4leg ztYpP`Dee=*=zqQ8hzjs3hW{%kh+(sn9cuBT0Ww2Pfzi!2`p7FVT8EY?zbSW}Y)`Cv z8kvH|-dB71Y~0TXurY{j4SVV-hHt93jwngQT7 zFYwu0g&^^mk{hjh&HqTuYsK__Kv8PG^>M(6&7^xV0A#S9hgoC2ogdEQS0OW1LaffR zusc*$T<2-5KiB?}7Idx~`2Hurxw&@L-}mIyYKpL1jqa??^`gsF-Ii+w`w6E@J{|wgc z4PH(?>EgMc4>qF;yo`yzz;jB4H%Nz^)A3VI#;j9`_7Z2=BkFs}34HwE@BONeU2E7U zZfO5G^5$|gFve+{hR~b_TZ^_H4}7;@Hh2=JUj2KMn>)XTdA7MSlPQV`YCJB0HA=P; zQ2&$5^Var;>eUBacIn6a@>u1wCflWI`@v)J@SEjlq2S2(77`^#pDZJLiyhVa*elk4 zz8xwpmCbm~{;0%I`?512Vf4mn?k?zM>j*XzP=c@rL=kIbJw$&TZOpKL)fYmUC@L0>q_ zn6Sd~-mln2XZ}4-6Y37SJcCWP-`}#CEQlL@nzlyjrbgMUyBz90={Dff0fU@t&Vc6RZIUI%RHr!TN{)12)*6ZaA;5?d`)07^DKx}wAptBYzg;?hMQJooC`4*$GOfCaYd@Zd(Y6* z+o;5LY5en#;@2FCqxXfu7^>yq)U6_?Y!PGI9Pyst4Ze?^1oNZdWF(xBW*}xT>O`;6 z$gJxZb;fj_&V@#w+k*cPm_wSA{c-AKD=tR0t#$mE-2&iitBfRQE9^&uXTqzR?Hws3 zZ}xhy;4i(CQ8CFd+-Iy`4CZSS)BSu2!ULbP+7*|INJ9(wLzqicIQhe|J|>CJjD$28 zGzA0&6y@4*zKvs6J!=Vc+58iI=k+p~+5W65dT%F8LeOuAbZg2&gFZ6YSocLbj%xYV zZ={mrTTFPngZSyGtkA)2oIlIm?-=N#*$-YXv5TBlVwJ;-AFEN)X7P(8vw!2+Oe)i0 znXu#5pmR5^BE`?NV5A|{5yvDONw+&^+q5EJ2 z%9MTF$bZ-NxBNVe)ebim+)Q(jxVbU59JXn6bH=M&@dR8d^~2ZQuE6(kC%K`UGz!_B#BKtCy~Ocq=J6aa=N20 z{9!P;h_2^^2-dge0~5PX9>(1EW=e=N{7ppE^Y*(Pv=6$171TBwK*`Jx0+Qpr6_YbM@rqox%!VPSO-KjE?ITU)=2ZdwKo2;3_*ijxPBT$kF(P)2-+O6l=0 zfrz&}_6=$dBNX-($7zw9GzR$L(oHlx;;_`@ck>f7d>vOUi5AXke|c(e%|;UOn$N-AA&<|QE>N%+ z79fnhY4WEcKuVZ~KW}$DWGxvZy-6Y(yq*N6`;uy;MP*d(XpSCliBo6yygpr7m(;US zon|8H)!x^Ban=sI$h?S0w_~L!Kh()pV{ z3K_AdOi5BDozegXrC%qPt6L?=BsRwvZ~tuBKL06_dQB?mPmX9toQ=i$9(LHH$1*tE ze7AkLXr+N$Fw2Y>_|#W2ji7}=Yq-KQ92BFGGFAGn*l+2U{QbvJosyRS^swk4$h^xj zJb^D?Ib9?E`o|ZAPKtOMJ-4U7$;kH9a>XO*1vXB`!Z(ZeghWyR)G5YT@ju+~*G8$W zq7Qe1ppG`{p(_(K(q;@q{7g0u54dO<;1mFC^-jnQHAgL(gla zvuw7Lj!;t)cftZzo(ikP4vz&oF0nmS0yAmZM?vIbZTB}(c1URlwQ#PqW^(DTs%<75 za}u6>EHxw*ndBVp-irm-%hwt5IXv62%QabtlWi1%883K-?ergQ5f1z+A&d)fcNLF` z>wZlSa3+N3xK$)ORoZ7C4iG*tM@98&in4=ttw_&8friZqm=()QE^*oi*ZW!p{{&%C z@YwHAYL|mqAbWUrSc!0v`r$w50ssDo3oEKivuT$a3r(NYqG_DCizm%llH?0fGk9Lk zyA2pw;&9hVdakCvrPdp>AWLz8Y)&A-Xu=bS&S9@(r;An&%z2rP>a@*-MC6w5Yz+f* z;(K?#78IB>J`RY)#zG=GSTJHvz$4ZKbrKbQ`fQ;6rs18uU_<4_5FsqB{rQr8{`Z$$ zt#0{rIY4i?cquJ-+|-SL!#rrI3SiUpylPuO$z^&%Nd3@8J<@( z4GMsobwX1!O`E4QV@G_}VJ&FS{JQ<+=&a2Rf9Ga5+82T-$Tw*2;|pe|{aX;hAoMO>(p(bY5A8e7|^<%a|oz4^u<+(>CS@fjN# zlysoMo7HxIu>(^JZj@;8--_0{YrX|6pdU2)n|$_>5<=7KZSQImiU3kKUVG#8QUrIy zV;}G2_FdT7mbciX8MGOYhPUj`9YoP`^(>^-0pL_Y{UoYhZ+qmROV~@Z?wmi4j_qE2Ra&*CVMj>W*qT6U-@)N(+x1$@)~hOL2{`}s3UD;ig5oqRFEm3 zba>Z4&-*e5+q)HOe4}rinUnsNvLh7-oG2klwy@ts`1i_t^{epJ@ulnof^c5Qq%66K z80J8r@jGPPO4OO9n?BlrgX8Mp_t>?ctG;dLSDre}nL=?XrLZR{CdQ&L(^*w_rJ%l` z|AGtfUYv9*a+IH%ZC>h|nR|!`r0A2@|Bhw~8EmO{{r#(OJ{e<;c)StX+l+{J@gT?q&K)#7*j zvgW1hJw8u5ARY$cim8e&yF}S%`!!`?(>gCA2e8JV zvGO3y#!;j8TZBe9@hIRJguw0|Qc zhb(sr$g$06LDZ$9YY^n)tqWTa8}d%3G?TH3$q`9 z-qjNr8L5+@fETK_NJkWxt6(q2pZZb{OJ4K&fGEjZ#kmsFgO$f`phH!`jZF(rQ&r>p zM|P@e0Nz{|ZGy~W9sy-sy{YQ~KSwkx5pMgXw|q0%FPt_bzU(Wtd@*ACll#_#)ud%(v!z5k?gaL3!li4CUy*`UQZ_uCYytVXK-`!dr;>ZGnimu041FHUyddiCB zYIdiUdcC($-^VL8;~+DS0-(mbaw<4E(KR_@;pR&xA*S`$~hz{x+pnvw#Mer<;lp+oH#VgkU!;hx*3G(1bMvB_XuqcpL z#ExiGUkwI48o)N#PlLkL#|aajAke9F(a)~xuv5A5a+nh7DAO$7p~{s^D_FML3zvB z`#{I_(Fw5wB95?Z+)Xfl=0g&%ynHgF@w9PN6pz`lh%C+hZWwjnrvE;5p+CdYfc)6Y zg?yao=uE7Dz&B9j6WY|1w@W<=^bkzY^jk{5gHR%q->OmODFXHsqk39+;#`jM0p9a) zanXkt14$COBrLt7DU0f^?MtH%$PY3cO@b6DrfK$4c|djD@CiXS5ak1tGQ)`y2VyKI zyxbgEo7>b{qd(8wFdbYJ3(Q z>^pwxr&}qSb?HGUVL4pkIZ;5}lpg+5xPZx2U~@$`A={{Az1%2S$7H#|&;CuJb4O;n zP{gh-<-^OyKW6m!t+3Elwvab`Nqi|Jvr3@PTKANff#?(6{YB~ydArZ9o|K%L0+&Wg z7agsw)AvTKedusG;lyib6)rqkIuZ%~!~Zxs3%0hpB?<=!S{#bIJG4M?cc;bOo#NU8 z!QI{6ofe7}cQ5V^!QC}C-~9o3^5mS%p4n?=y>s!5!*>Q*=D}eRFR6m5<&wkwkbH=J zm~@3syFm2*ezEz82)bQv!^l)!I48pTz)nruAlkr5RDjBuUV)tHFKs-|mQksae((2q z{^R`c)$WBO?(K=UIJkr=jDCmZ;$boi$XF?(Kyfu;)gq0l0)XY#j^{Vqs*vs(Nw zA})bdtk|__TBkr<&w_vLI42EXU#KH`3gld%L_@yksYRWymK{|dDDgGard)86Nez*_ zWS92}^qOg*BNjWEZkN*si_NxkXq8mvr1`b}|M+1Zko*5j-u>K+>z{wj88}|e6k;A1 zuA+IMLLoApDg6>Qj5!4TJVx5OHLCyMury>_)FfSgpmN@!Oj2lrFphT{RKQ{A05?#I zF9yl=A2H)&k452?`&pqH$={xG!&4VDN655WToeN(KRCP~2=7Ikz98o8u_o6 z7`G54RgDxKQ)aYXR@Ll3U8c^X5Q=AE-)j?g3`9Y7r{ba45Bpma!q+jex4L}V#8wc( z&mAP^%_}oSyG?}gENw{zwnG1OPR-A@^*NXC2X@WPf|^ykSNGoN9v(YG^hq~XsBxj^OiEP$96}5qMyTef<#*bC)k9v@4C?RJvy1r5yn>Df6!L>$4n#WOP{ONLHbJbiP z@MssIQ{y-7znQhqIRz)b9*O|&`KmCRG8-u;- z5T^XB$%@z6(j){7dKI|H?xv^-(!_%&M(_3K#wv~-3HtC))7vs_=t~TfYo3zjteWtU z6`2qz8zPMXx@+Ps98`!xc)d`bs35yGe2B5hrHhCL@{nByjd`-Nv?9$Mo?`pGf{#9m zl{iT-#4i!DB>n>RC=a1qx)q(6q{h}gTxAzRH5<-_{Z2)zvVWU$?j9mlp`|wJQt;)B+{4FJ$x> zXEGca1=ynvD46orXb!)3b!Kr>0v%aGZGW!KnK;!kinK+6E)f^mH&vQy&OM<2R1y zd9h=;9hU@66(@z^T#f{P9X_~{Php&2c(1TE za!mk5T#A4BHoR1GfLRyvHjEk0mcZlMc<59a_zjnmWtn*7o7#}eTBy~>)6d!6j1`T@ z;EVJ}G)i5~mUJuJU)m)N++V;Onvq?JE&&g?n$n*O;HU?j(-l%$uX`aGm=x&2&eU|bDrB=T@!ehtqC0hc$mN79`s3S5>WJ`5aEcAS z#{uoD#sq;LV&nqf*bQ2oFoo6KrvUHR*dJ{>+R&@iuGd@JI_KEm0U#uO2I!W z0@TmU^3BNmaHZ}T0q+lu^%oWOXWyHq^wyI|H@k@Ah~nOQ%RbZot&@fcVacSmo;~(X zyAXqPuO%$G@_L2O+OMREGWON~8=bKIEnS&F_A>Q;I2-2>xXKf#VYec6KMHX;wiy}w`pj1Jn(Se@a`OGA5ILk`+wrdE5irAq#R}t1 z#w_FIbO`DWiOCAPp3NdY5q!^s9PK0iu^n*6kf8c9f=p0z#&D4-3ZBR%Ll+{y?rFD> zJ+-CEE0E4Wb`gYyQe+u>rr8x$@^r^N^qa$^@L}D{1x>kpFwm01wBZ58Dv+%hot(%* zqy|wEuB-mL>0_vtLoUp5>Q#&wvS1j-)RGnHVUWldRH4J%iX%8}LwxtQV3cwSvO(Mu z=jh{sE!G`61|Jzr-sx;BmxQ9!%d4KZ?&EaU`BPn1wy6iJIOoHpkoV0vfx(UW2?@uU z%4@Dq*SWm2Spl~47_Em0 z*;BC9)e^)I(RXV-@B3F2qvz27D?m*=u`psD15)Bqt zdPUZcMDycXhZpPxxGS_$j8DR)DHh?!rK+O zILTUv8*$kXM#|Cw8CVIzB1k~WL*w`+D#e`pSC7_G6|kNu|lK361OYd7T|TN`x#beN9&W^gA2iXrgw!{Jb{y}5$dw`zHO{;-zh;Zk5@Xd?82ZHp5lh^0vjTjHGHk&_V&42kDOLT z5Z6gdQeOjAaNmp@(1Qt}NotZsHhw*O&(I8`W%;VF<^q&Cu z8GFLLj|oys4DHXv7AtgfMG$DUdk6}BFQffTSz|Mb)YA7?^&=u0)u*onS5J4~m5v%| z6j=gbr<=#!-G_ZuQP=r+l7&&nJ5~>0ABky5_459fUxbZI<6FRZ&fAT_yAyp(E&v}s z1wny>iMTi-CQ593a%BgqsyNsW#nsl$n}p|6IoK5L?BE4gey5(|vJ#vizvy6{EP$+c z@cdHku+oBiRRn)-ca)D`fAZ@QL+=R-KK~%#nrjG|!U~J?)KJC~YLl*b1vDJo9`f$KoFn(@|sL>tSP z*(EfXc(<8`s>r+eLe2M%b~7zYbv)rSinb6L&?b~cM&-044pa)Zrl~ML^19jC8ZZl@ zb>>y9s>1jq+?6B^ETs7sl*lIDX7gTm+=%|KweqwQ?mrsk1&3lUOAoIUfd(P{Ez&y>Ri z6>Kp@dn#GRzp1Gp;fKsd;{~#@qIq~oH|V!{Sh>z>f}WPwBUrLD*e|&F)lUBie9RGhJs0#uoG4q>=d$w;v)XxheFxmKPyKo^BAbLkhZuk>reA>r zoo}H>QSFEa?!jOt4%kz+`tgpjQ)}>_;sv>0sc%q2l&KAC2;??Sc!`B81RGPf{6rm( zcD=^InoKen9ZbH$k0Y>OAy{_T|MU_v2n!`S`B157H$C|v1qW1bsDEP+ zZuK}hr=vsH`aWJ@3zovlDn(~Q*l79WsG+S;J& z{M+=!#aV1`Z|Cazg`Gq|NRomgQBsW#Ftf1mVz4&!yZcEK}!vtA6UZJ2~o{{RCchKG2x>Cf8mpZqTm zL5}NHVVsCp7Ak-Az*#RAd7b}m2coin*URv8LR=Gi@W>pt+-dY0eB;8fq9x`C(Rbi} z<5!+$cxa3A-5(-Nd1_!#b(S8ARGBxC zbhCsIjOS1x%>d}MS9@fP1eY@Z6^IG0+&>rzJ3GJ=NGyXtcDN3n{_T7IDM3C3^Cp5I zryXu~k5sl74dMx9+QUVw6kx?trHYrAlwbCWx4j#EyXugGHrZduv1CexS89cF+ zUKH>53Z~uf$*YxrbIf*W(Yj()XtR`7{N~xG3h(mAK7H58C57S5e*&8rNZ%OJ-Kdhq zCGzLkFgp)o;%Ok4Du17qi#GT*+TID}D(>XiNh&ADi_ch>d+c06r+mn32><*gDj;Z; zC85J`$CfS7ZRsP;tiW7>F$5@2`sULc>A1`d%zMJzT`B$pRec*~3C<#iRqwX#kJ}LZ zjZ5S;OF)>m0XcntMk<&I&9~m2!xX5JITtc{Ir{H%|EamOl=j@mQ@!jUSR2l(J9c}t zrUp>}{EbPw-tT6BlOBNqsbu0Ls+rpM?bVTdDu_(bMD1E)Pc7UY7J$fD&+;h8Yl1Et zoI02;NQ4+#2{!IOzK8lL@`O7Ze9t!i*y$|F2@+q1;Mv$jX}}l$24kI%Je!rOh)9*E z3%f?DYIy9IJ_NnLg@c6KF;3w}KV-n&BX0@%csS)lK|QO#bB;fWN>D7+f(`v64ChWG#foxA4rmiaeL}qOb-wW2+Qo`>0u)++;65&$s+#meW|IwT`=>eR$mw^pY&mR|pdpH1B% z@dBv!=Ks0-|G=cIXjk!u|UXa!W;}Uj>38amVQTp38s(-3 zFDDFi63H1G$7;S&hpq}+zVxqgNA@8r&ux9k`vpPGAZ2*AAN$Zen-WK23Q)pbUW>|1 zv{Of8EN$Y^d^38>xnSq$k`9)-fQ^bw4F8-eqFZ3g$?t8Gh|8R!`m-i60y< z33+qW@{NMaS`}9PrspPd@s9(0jFA3)9L*ycy2&tVz$0xBp(gRUCH(bp@)$rbK~K<> z#j1oX`eLq3%Z;CB5R6jJQO1+m-ifPT01W>}MgOVhtr;@VY(!R_$1)g+8^YYz12rwF zkm?cQ>6YV*U*WE_De2Yc(7EyVsI5x?@W?s``nKA6yG>#Ji`qXxG4csF2*BbF3Xh0^ zQ)!x-cE|1&Byxl0)<1nKJ`qWYyX1e}kLEthQ+V7c^J&cXCe=0}0hV|Qpr>Pd`^(K` zy1%pMOIZT?e6T}jhM>2tf1vT_n4WdX^6$Fpzj#L*I~Nw0 zpC4C{{QC|Z$bO1a9w2o${v4Fc3%ZTkVB7R!h*!SI1I(4jZsDfY8?3(F)x6kcWfhGu zP>Lbf&?@g-hq6QZHs9_#60}(uLUfRH^K)=YKL}yQZ;2rVvmkN>fnNd^sge-@kprrZ z%EWGl7%!qNe=rqAu18#dJ}$F*>4dN3UG6PQ5VRn@c{UaK#IjN$a013J(jZcz-0flMhDUwRF=~JgY;7(~$A}MFh~`J?RPc843;w)@?j_iE(E$GN2dWyc zYyW}U$YDZZ@FZ^&D#HNaQ#GeL6F0*#+g4H) zg=J|iuZZO1>R(Z1e!mP_JyA*zroC&l#rO|n9*34M31BJt6>ffyJeh9f;ra2_gV@(c z=`c@xYOsAcSiRlhJIX&4I`F#{tZF2Ev;?N8&{s(*xQI3zNgBNA32V)AreNt_oTUDL z=`@UX{S79rRjtksJvlL?@|f|3O~_KgBt`ld^up~;Z}eS{G)IYcI`k3y|43=vD-|i@ zUjqWxs&f)L&a({00Cp(1Qo*EKeQst{Hw4}RYZKHi4!k`0qk$k% zC!RHt^O3PbRtQ%-U0rR(n9Wq$zobi?*fmyiMI)5kR7?KrWj{{VvLOVDf5?F?mq^5k zFDy_DXLl@{a#*Wtx^-bJJ|oYLan#q=fQcw~GE=p2$FSJ0Y5s5G(HH37_(gAS)cB8s zTk(VzK=6YhG8T&tTDUx5OIg%3+H%sT$Vsa^u_C-*yPXpk2ZNZp-5K<&f+fcc70Gx6I+X4868 zTfHZ2%GtO-OXwZv;!@bu%6aF7@cEs=-c{_8gy2f7Q}5Pv9X-BUl+gVYt5EzpsO0qc zKA}T`x_uLtEdyYEDQ=KLk!WXjj364wQSXo%B(o~qQ3;8HykqnYt~Yw~a0h9#Bsp*~ zjf$JZw4p^Ek4KmtsIZX|%QKScXo;;6g@cdSrZLsmg@D|g07?b`Ct|)BE3>d#a?O?g zK?=}A-}M-AbH4XK56WVYilr9$^4Mv~*%7{KexzIsGlqwsyDOKeP8+HTdyb*snYcb% z09T|KaGJI7WG-nq`Iw^N|1ad6jRV@>Dv$4}V5;Rpq^XAvqn_SmVbIBmvz%BS%tz68 zfeQ3l>j)MX<5#&b*jj;wWbKB&p4>t0 zq7n&2()p5!jX1t9pv%q2E29^ikDh;ch&NhZjD787cDlLg9stOc=}7B^!$u?p|3o&=7=0w{P$hgccS9KW`5&vY@jD8(TlZ3ytLDq6fF@3k4?4UmSZ(u_Q+rLA^ z12#xJ9IkQ)2!pot=`YCrU@++GcWjE3%b#h=9X@#QLh%JTZYW^b0BJ5I9Io3(=&;A; zGJGmNh6G^Em68_*l@j?p?&`P7`FEag!Qbp%d=5o?ZceXIj%&RA_p;zCn4TK$_L^=H|=PzRyuAP zb#;AOw&`#DOo1e2U4dR74qp1!^tpO6m%l+B59WMiq49O|twy9}LM=wlB}J9CD43!3 zg{a0?jsCo%`)}tS>{Y76lAj;^*z3(I8fz}WpikP12?F2}$VR3@3NLKP?H{%({!q3Y z-Sh5zDTM?3(>=INI={nlu>ug_q?#vQ?7-s^=oeT?LKxagJ}?pjgkz8x?@EY;9^}JVzN&tXs2p zO1P!ztic3RJ^uV%MshJj73JdkT975&O1i+qKG1PvK)WS>6+&T+2 zmDSe7X*7y*8pa&)Z1Vp!MdIIzrd)OLa*)x?Sl_;fvL~R*cGFf@tK|vbMfC#fh^u8z z4UD2y;eZbYWi}HPFoUp$rd+PxzXsW1jd)#Wb)%x@i+CSR#-JEbVkPzqVewzYcUAc7e?QS{eZc_%C zcm8S(?yot!#8T5l2+!XjG2$+Fii>0%~_|Lt#g_{4u8qs3!D2uh`Z!D#x#2;bD#3=9A)2 zK1Bcj!C?FAkO#Qg$#YSt>_x>piNhwERrQcVp26W7MV}Z_2OuFA6&lpB8NRxS$#1?Y$(NpAYs1t=j$W8sl52ldC#8)ICbG9n#^akzr zEO@m`n~Jd_X%nSM0VOj>T2mgcpOS`BE0I1}v&<{~Dr8yD{I2=XdkJ|3y;$WrkZh*n zP>D%=<eNCf&m3h}(KVMHIoVooGV!Eh^Zc z%qFjIN$l}uPkSqcaiQqU^H3XML#{9o5?Q)S{Nt4Q5&0L}P7ASs22h`@2{W6-j2ju6k>-2$r_ zC#4A*+T$bubSjknYXr8Euz&3Aqt8aqep2sx!=CVRz96DKCPbCToF)Mcs!;{1ka_iV67_9Xn%>~) z>&-W%!oIG_i3R{{?;pNr@(&&=D%*yCb0ixAL+@gXkYg2OXzTeBf7$j(WE01;$WHMbYQFK1{W~9h#F-2U)!UHQ^yFc_d0w01wBKDV|h78kVR`hW_0oj&`H&LG7lfc4gOKiLq;5R|oO zt{UTgRv{}dr%~1%@Lv@EMfUepRsNMI-0_h&?aLLPfCRELT1*mE?NaX6!rNKa0}qch zTI6RwR3s*OPrFD4XrR|+3;5bI1YlZf%$@6U$x~>0g zgE=-cZW^77N^vMg*$R(ABdJaTOL2PsFJu3OGjn}0Uxep$%zHck$?BX8*{c!wueR&i zgf=KQ((|H-T}#HNE(B;y)=%zMdiE7Qhny(4Gy9BeMdZ4DjK>&U0>ENAVjD3TNA|?X zZ=()fDwfm1ODaWgu+A=_n$u&)C5D7So*X+Gfmv$?gh)wZb7n9I+#2QmTSU^aInq#i za#wQ$r!wB?sAPr;nH@?g&e;4@60=ntJF6B_@Qj9jg8u?pd(MSLX?dbS)M`h$IA-RQ z)&&&vGL;4ZaQrT0kS*0b{PR763sJ)}T;$#QW`lqP^Gx#Gjvzd?ae<*!Fd z0BC3!Y`nQap@Lpcy};GjP3R8y)FYY@F&0glx4j9RgER^>Nt^+goZsXr@XUYyt8Qa} z#wwxQCAenePX%~&EzO=$+JU!wRH_<@2sxs5J%dQiWcF(tOB*)U4B=ar7D&fvnbQqo zyddyWMiR4vMf_e5X`{UlOk20y!bp{EJRJ^(f@jt>`P=dY2!c`f9j}ZSC|<@}K0iv; zt}*BF00%oE{UE$~C1M9m>c!KQDo0{)Ed=AFvAe=`gYfGu?X6c&Vn(HqZsj!7+x71kn2>)UwKYka-=w0+fe zpJu2J28#}g71GMHMC7Y}lvN<3#iZf)-LeLD}jlQV;s{%kP+DB`&o2Mh&WPiI=q zsw5J>#7dU`q$#u%UFU zj1&W%bu>)lpeO{SUp)v!TlL0p>i{wTD7qf_pR)jbeOCpC7K(1}C^YpTV|UL99SuT4 z3F%;!Z3i&e;pUS5rKoba{lVo*Qc}w8F$gUo*W@~%!)Lb6sLOAjp6X!j=Q-awDz;^J z(+L$fJ$E4v>LQN6Uk4j3$DL@Mc0fpc2A|81zd{ zYJdDB-OU%@$Epz;K4B7So=|HGj#bP(eJqN}xjV~g=#!|Wv6MXTqc9{f)Sle*J((HM z@KQfyzz@~PFttfvB6Wfn?ce=MiS6NbWSH=_3EAF!^=B`OY7@T+;GAotP<&*5eoFnmTCrp%i8`6 zYD%vD6_cX7m?#IMG4X|z{5@!U0oh4hNVs3I0AG*-g3MIyJKO)hfL>J~J$Lqkb7x}1 zBZO=eFq}&P!@*myn88DpTlzX*9-_jC1+sqb!s=|pTGAmYRujY<=;}f9Mk}+`$OlxH z_`bpjOyg(Lo-$lg1x%Hq&$z6dSIRGE^)Dd*RLEUzg_)7h~YQHSnUf(D|_7G|bVhgeb#^48?FaWEC>3BG%)v<;rgUQt-4c{3LtT~+M2c>>Te}d_0bTo4Cx80z zyWEx~Qqh_(ZOo1tN{h)h3ShvDil*2*Co)ir*vP3=r{VEv*2ae4@4;h#+kC%T2fZ)P zici`SFf|cbra-K*wbVzmM{OGjdy4nOxvVJCb+xH|SMWg%@W~n}N99(3%pk*F{@RU#mV0zZG=hPZD8B-wZiD)W7 z@5EhVSW#Hj2G>vz$Pi?50_sl!oe6pvjBO>i`nRCsF83(Mj>{q05?$-_MolZw2G?`; z?oM>n1YH>t%1q0C#`?tRGPXPgc6+;is=^ls{9;<`AV8HM{2(vUJO6=786`^10{}(g z)TPnCx}6ps%Ww>5N6Vs78W*>`Ir$un+&F6o$}*sLf^KFI(>|G7yOr8fbb<1q!i*)E z0V8n)h4$tUyQ3qCmlMawFvpru=;(Hcegbkbz-zL&g!>+|rpl)4`OZKTQts4Lh754k zssoOhlAyrvGoWJRevHelA;+~ivj4kh{Fypt=Z6Plm&^8PNmI`;X0fr>XWHN9c>Urf zF*(@R)K!A|s1B!^KbD3@Yt~P@o$V!t})9N{YLrM@m$8~8Shwn#tjRfltLMK3R35W4mx1C zXx|$m;p)qt^uBrCp(u0yOY4-T@FoXs3nIqno4;7PH^ZN3BUO{Woi;=<% zcb5Pb#x_^iQnDWvkB(D-73Ppnw9Ak)(th=nNLKy;Ghb>Cb1-z%ufrFlrGY?5IUCgV z*puQa0hCX=fJO~+H*r)$_`FFV+`xr{5aAX35Qn8e&_uNAe zCVx3m5Bl_1kVR+q(g`^u!n35f!<+_p!?9Z+=01>-A23W}B|2XSblq47;r>+FSvlwn zw*MK2nIuzEz@n?q5XjD0Ut`5KMNxC1x&xlht&dl4&F9o#LmnMFAFY~qh4ZFJ98gU1 zL?`xIkP3^-vW&gw5K3qM1tPm-Rv!NJ?0#S%nVR+YX)6Q){reJf3xv~TS&Z>PvgjOT zeqERCt~AQ7(g8>1h)Y}{>49ByOb`Ll)ZEzd^~zye`nqT_Zuq`=GELa{cD}hrUKIgI zQl9=}90c(qFC{483}cNz)g+$gf&t+Z7Gs3D?hSI?4~21D5`Bg*9z$_o?!Vj*g&>+g zF~o)iMRQw{O`-P2jN#WBpb%03wK!Ul651HYW*eG2rY zGYx7x2VF+wCcLrEv4S{p*o_ylt>QEXwDWcK&h>lHi-X z?=z4)$la6&0*~k5^09HbRF9%^#p%WO)7-`~^>!UCJeG>^)c~apgty=BzL`8L0x1-s zljHtoCJx?n_d_?j7#{29#&hibq|>NE2D~8 zwH#(NO|+DjKE@|_81q^%-Y-GsTq73pw=~Ey=yIAU)S0@B!5<4JI3i++5S7jhR)&W| zoNR4^6#XEnb=`wsh!QIDHl(1~ijsWdHi54)D$$a3KLMPUd+(^j_P>GNpeYO5dt% zK%#=;6x*wGGO0_G{kJJv_~gT3oMN^PsN0+NA$N6oug~WjNX+sI{v5nb=~YM!4h61t zRE<@OIfGX)0jfMF%`Z}jnP!%ap5mzBp(cJTFm`Bb(0;CH~$^N z&AZUXiFiAnjp~d-u|!be*G$;quif(U-fnyll?gpGaSN{#MyVl0S$&4wkn8JDz_fI$ zE`7_jLFS;!NLWl6{qHM6REd=G=Hn1tqsa1R%E}tZhQ+2w;9qFTYRQH5H#eA36Nw2Z z0wl(=j|Q8@91Xy04Y2j8=< z^aABsBomJ0i8`li20VWq;4ycRY#WX@0mm{7te3ygXqTl$`_)5I-&mKV$fA=KKKKoB z+4d7tYS^zv&;{hW>pco%imE6_G(kx9`mk;X$PlcWS^@|Nnb|^#;>%tZqY4OLOJ2{w zWE8+%VRX8f-gv&r`RXPc&$37Wr_YjWM7GxQ*JD2$l{RLvTL^nf3>e6T}}t{uLZeD z2|a657X5Y>_CIuRR{;m2_Y8Jf8+Ej3DJB*VEgjt4a73hk*ZZ@vtH_`;$!FGr*QB^w zef<#LaQW_S#mQirx2wTnr|FU=5#NUs-rG(0_!^nf^|If=iH3#~l0XL_QbI-sbaU~d zc@nP)izjEQFU98tp10|wgDKA1w87&@^$APL6dvd0tKVLMG6RI1X*>o6OdDyhId|sf07N~yz zlvlfAJ@ODrLZDwBB4&8Z0HjO=4(a87jh463{LWTtn?*1^%!L}J?9~|;rsA3Vot#8c z^w*3ajtPUFzTRY=QK!=piA!iY*koo_cE|wpQ%nZXvn5EIq!_^zvr$@0?WIg5_G0ma zfn+R`ImNJPd70T=y z5t^*}#)yRZP^6gfG7lXDLV^JdQ)nR2L|3AeMh^|laPY`raTAXk2DE4056pHXTz@X| z>Ad4MeeQtt53Z}Mrh#5%> z{vGCm8+|!pQ@JAKI0)kkd&Lcyu~u69*DEsvu1^{IF0d&Hg$Fu#HJ3y?o>)uKF-7eO zGT);9j4KR?ZA%jUI@mH=TYmKU`4|Us_WZ4n*2m2*BQ`u6&Y?8+;Qeh25ocoQ@iBQP zuPx=B(O+1Aqh7Xfy+3%O&5$JP`se6}TRU2S41Cx`3|)5wvS>|@(c^H%^u}_R$0*0# z0Zr7`n0L~eY>S5x$3G*XT%)QacnY24*)hw3=YMHeAxEG`E6B#nvd-pg(7ITzT58%? zT7}lqaYA%rr8CQ% z&pWH@h*^k;sYCV21+UY0^PX%om=sEtNc3O=jhVk@ZB=xDU_;h9=&R#;uA>`E9;oW+ zeMTABb4Wzt>@^$m2`ck5- z*o+9+_J@ZDG)Pp#D8nUVtg-x&kvJu^Nb<;3+dh99lvh?3Ox->^n`ZCweRp)Y@V#;p zEagN}*kkK0NgbbyIr`2_%S23(J7_B$a@RVKawys+nPBMz!9_a94n>CHC0g#P9}YvV zO93n?2tFhP(N5e8^m1D(|8nGX6MoPW4&!U|`X(fOYOs=^Xf}}~KRm9_;A*BtE=#$e zBm%N}0zsgr8*kDG((vNYbi|Q~?2L3LE*NXD1QOexKl3MEf-r0Xsc2kJ^4bd$=M_?W{#S@PXjaWve-L}MDh5`FJ z{bv=t0s1E=^pB2MO>QH6ptL3=6+<@LN;7h>k5gx)e#9Qid)pdaMwNiI6F3?pURubY zJk;tk{41-LVe%skpdQk#T#XMz+8Xhm8E|ifIL=YgqwTyl)w7jxLDg`VmAD7a#a_OO4Pi7!9nf85PBp*g9CCXCl(i{@TXp_bC!HoU#RGVp83=85=2pe?tJm($Y|wk_0lE*Ibv#s{b*{LN)8tHJm&AsvP-hGlCZ=z1*dWH|KHm=v-VWVgsJ zMitoFqJRxh!p!2~pX`)atwE z``F#E>CQ8rMDSE~M2d}-hkf;eI+7jsA`lL7BQYLTaC6}4wiE!c!ur?Zl}m_`ViL87 z#!oMe#E6sNoA8wvS>RBy@ax>tMe7?q+n1;hxsPX#c?1}^l7@dlZ)-9<2nC|(NIh#@ zSDN&&&h8X+rK`Jn(D(CjC1USF3x~BuCMV7%9&7dUao;M=zFrs4qI3X?Z58(MltYDc z`SLMwv^em;GF^lL<}!6Kk->tdXFQnX-02{maa;8(03&w*aq15SiYuPG%R_gk|MtW(7IE zq2G0K&^IOM7sJ{a2b+P_AvDTq=uoB+O04^Dzm5qQu;v4CyZmuvpl$|WcBIF^clyfu z9qDZ{LQ?D&kq}n+iMxfhui~{BjQBRm5g?bdQT8aI_a@>UdQ{=B6S{??@Q+W$!g7NO zdtw78)tN=ej7l6CfzT~P`e5(`k_9>3d_&~%P5F`|Zz+Z)KAgh>Exma~t8k?Dj+|t# zVhu0kLOJ0HWh6y811^Uf_X3?nfEpHR=9rUXNxe=v&(xB^UJuS`p{`^Bp zhr3vF|EgG$Ce*;^|7r`Ga`~U?`p6Fbno+`p4)GFO@s}s;c^5K7F}teusmm&zo;Hl4 z{-V&WQa$5#8+vybNE-_N(5fQtjj`b_Sw^W>biB~ES5opgyxTBf0&dN(zLosb7X)Yj zPi_?2Bd>3E2aB5Kypo^Tww%)~$TS$Nkqf#}Jteur_-*uDHt7IrT=_}>0ztAkOpd~y znz8|_sY@fsd=RVAYTP*Gp7|$fDFl-yXFP%hglC*vqg{5lh8OX?cS*{XYGeLePs`MN zy~&GU3uOaV74%yb=ziuEWoN(Vq#gY^J{-Vl6#14x_~v^;YXcyq^?)VAUMJ9lON0}{ zI^v(~1mo^!X@Jf!i|x5H<^$w4XB+e`@&htB+BvjtlIjgpD%z_&S<_Oxyxz-dk-TaMeeQ&t7B79mX5HrdU*( zvE`u>ci@0^%+B)mLuwJ%me=BB90lOOVjnz1A|6tx>f>VP>pD&gn^kN67)72(PLN7) znz=^&jWxvp<}(oSVZHLda}F7h5I{gSj#R52(@=KgK}&S-c>_{-AVm$c=_QU$J*fRD0$i)p(k;m7+@pUq_+YGygbXz0X23+&duf5#9~|9$4Nzp{ z4KK*Rw?eqSPq}e4DrFHTu{FbjRpkDR7)54&h`>{M4L2-$?2rU&kp>ud@kd#ba^Gn< zVM~2KOVjK6EAW2e7+K>I9ordE#35XFyK0<0^Az>O;k@1B!%Ji7J(J7Npeu67t;$9r=U;Z` zWCaHV?Ap33P*v68lQ{YEaRRL^oUiUhh7@BNB{C;>mMRxyMs_(jWha!S5UcQV<0*y3%WNC9yD%ii?ve4Edj* zZ8bOZ+$#kRcMo0|@V&eddt1)ilL}Z0dNy3FRi5OaAP;L;xvRO zTX2ObfMEp8)_gVBj6i+_8NW+Dr}hc{zUoO_LgcB{Z}&$XhGFdS_XbuZ@o{6Ldr-rg z{c2Bog8=g;N<02M`De5W%whe?ndw}=GFpp*oYj#D6V{~u-AR7_F85;ySjb;djVG)) z=v@InRyij3*|Ng5r_|RkQME{lELIevc4VBMiogd}f_3``L3Vk?Lx*`e5gyp40d6gI zS^R#?t{}E43niR4CW-J$mX+m(VP`uO zDiSP{>P2kz6S;d+K-tsISEn*OnyK&^)Mn{}dnkM7(H@nXdueySX8bzZn4^1;84)XV z7noL)f|68w0|Kv>*MnrHqB;U^SY#S*tTww`-cSPrCtz`~3h}VFXo`A&vjp7%w=`<~ zil0B$VGfg)NMmyTu0TQLrYOq!|zc!dy`SR9% z*ZWhjhrVlaPa)0Ji6h&eOeraa*{wYZCo`mJ{@mp+Bp;kl{ny^d>eO;t;h zmXAHZRKXqf(FvG3Us`B$WByJbabjP=rqtrv+xzBzrr)RdhYl(>rWP3eeD7&y#&EAL z7m8o2Azi^V(a!uG-1^sgJJ{`RHOBaK^86H``FJ3KSSgWwS#Del@pSjI8VC~kdRlaI zVW4AxhUJ{RzwBp{n&W-z^I8R+LRs^ez$A>{qEQau^%6knt|F6*s^I=v1MTTZX_U$l z<_iaCLBs#2(_c?tZ#hAn*+!emKel9!zuJ^oR7A98B*b;(;tm5JHFfuuK9-u&GFfLdR&(21F*TWnc?$Z&jS9*dz{~s z7jv$L?$;nyw=WjgxP-g$$u07%kDE$&wo?ekgWF*Q)*80NTr2K~ne!i}QQI?rblam@ z#!7}&o@%$OR?HFI@;8zC5rL+_uJ&)NoUx)e#!t8y_97eLiSXvPRQpN{j<2sO_u9Xa zJ0^8(LWmXl0=@9QBnR3=B0Wx>`NV_=~M)ixB`erF@I(d_f>2dt0wM z1I{%iut=f-_c!qA-Sq;QIkS#GxO(=7@8(iqz<{fnf!I*Txw}&@gLrkT?*P2&Wgxg@ zq;4q|PXQA=-NF6!?b5I`98ht1^U}FXtB@+*V(QA@!T{~7AED>J{dxuE8Jb3@<{k|s zA%aUFGBm?(i7&q0{q3zM>jzfSK%Ip=^YO(c{@DjtfBNBtGvot}X9wiPK`ah~uoH~U zSZ!kCkYqne#Z2VYq~l3&WOw|x0w-hR{V4Y~Mh~{j=LLDTRB9A%ggv!tf~gtmt=kY@ z8%ldBx_9tdgYp|?L>K+1=bNqc&pN)>D2i$~n7Z3g^N^`>KIRtq>-*cq{^q+!+~_s$ zofE!>_G@ncEzNh+{S2CJ7j)mQ>J0=@%2ysiEMRc#-1($i)p zysOVWvE|7t%`zDkrDf;tZIr*dy?r1n$^*5Dr0wV(-t{aZhkG*v?3M8edDSMVhIGjs z9g01uwSD=&U$3G@aB}qNjuKbTPq(aesp2Tu);$s^teps;K(b++&pKh z4ZX-A)~|lG&(1Xnt$d~G;aYHXI5PqYLv*Zf?d!nJ^AOnG;7a7=3T3OTUN2YNpa%UDtY3>pro<<$$$_?w52mI6vk&kY zqfc*Bi|G<~>zB?yzkIi6H&2LqC)Bg6;Kxi)pljL#%ydh9+B2%TvlBQll^onnoS-IU zqPzWIAZ3DCP=mWxVY$;tn!mqGS}Ugk1^E01N3{pU$4AiLq?npxiA!ty#k+oz4!T@ zC%a`hh-_v7mV0^Q!s?%W|JsMwmzD_}m9%t?0D;&XW=?=GQf8#oQU z8+`6W=@C9N>h%1ee7LcHZ~bt4L3m7X<<(bsI|YQ zruI+uWo_!a4X>(|>k~WCHj!v){in<1Omo&O?w@=Acdq_%T5b*P;Hjp+!8>Iu0uU{LerS|-Oyewp@digqC(85f1h;PGMj@BaG9 zVWh!uX@Pr{&+|2MN{56KoE>D~CJ#6fWpX0-q^5I9O#yO+D$LnEz=?s3Y&jrT|K8if zKmXxdSI*?YMuLJF2a$Kn@YTJipWZ&)9m52LAq;{wMsxk@*Q+}L)MU~d=%0@71ZJAC z{MLyXVpwK;@8;#;CES$4&e-lej2-|cRRdre;SiQS2<#PqV0{PRRl+HPndVvMWpsy; z#6b6+5y{Dhp{MvWMrX| zG{c>f@Cd15OEY<1HAbh28oQeVFqQ4a)T(gJGzTUXeVU|+wI4J28nvbG5hk9;q5R_? z@LDT;3o9R|I4R9Mc;d&u$PctLKj73h51sYg5 zvqy4LXCsbeQlq5r0QBpX;GfE2f}m8#0oOynX`@-~{+pb&U|y22lZ=4hKP!kSu*D&Y53dUwGIrI-3H{H#!cNX#F&whBprVMfSXm1J7xKb66(gN#HG zB5^_3SA247``bsG2N4G0AZ}$&=FWa6|x@;GmG@MYO0667$r}B?eKd zeLInNlbguXp)|QONgyhwfzUtRlVd$c{au_8#YGG-xzRa7CV z{HJ8}Q!@ zbRPUV&kuCO@16IzEPA!GkEvaK2tNM}tOpDxvm8?&L6;phRKmG(ZEDX`dE0igavW;4 z9wt2;cP21b^BmPvfdPmJ6UuT#LLQc$m)Xz1e6kHQAN*M$C44Za>{tO__@-I*r0>e>td__#RC z@=&N+paa4;8}{tLEL(=_5|=;y@bVHNxH3UOu`TSc#wDBRuWH=A;TZkL(XW{^sT)TX zwFHDJMFH61rXox{ag}s{m|{6%GG+piI;mj9_-0*y{@LTldwxhu$^*Db+<$vb{^Ca$ zKfIQm8$cA|2$eY52ySMal>V*>mU-pE=~iP1RCUaRE{x#rIuWxu05_D1Xn-1Ea8+`J zsmD@4uC7L>gp)~i}rcAAOvUy_xqsl0`^CUEhc&k3LX4s>pJ3{kuSIyQL2+{T` z1*y9Nm849Zf+2HKDL|$nnf%tKMD7%b8@JV zOH#FQ&28)2gx7_isz#U6uW{=Mz$@9*bv7z|PJO<6X~fFPN>P^C(qLrRJiwg?&p2O-F^Y}fzI^uV5FAw! zVIWR>m(tmP@|2yu-LHNfwKtn`VgqEzoj}Afsu>7m=FA8}8jI!OlBp|jSfUhckMZj- zw!eO~9c6i}L?We6B*BW}?TMTSH2KrhhQu z!k*=ss=686ImjrgQFdtA@4nvI7{NFrVj*FOP@w8OmJX@vJ?I?1b8=`WLZGSQR2!bQ z6ycai!zcIGHD>B?jE>h$=Zg+zI$qXK^Ctvm4DvfO$BT;bui{4Ne%7;Hk)wwh7FP}{ zh(I(a_h6obP&3Khc4V3px?F#nbJ$TDQk+V6t{X?xL=AIF0!Xg`E@`+Vq48$cI4nIK z=YRR>z75VO1t$CW_p(Ko>n>o2>ENOs;8PQOxlaPc^ zwMsh2F7*jw{hBVB&Y^|v{$v}II{;<@2ykKdqCAt+&CBOl$1GfepFKZ#wgbwb&gKx| z`J|&2m(pXVo&db!D(j>gl^WDOt;p%_WLXle6QRn^wbHQr|2u^%Ln*_0`N!D6d zz8II!-oFSOpBz9h9v42{?X;4fMKAl<*Ey?+KlWR*Z)SJ)IzjWP>22qZoTp)Kjv4J% z=U<;}oAw{YRL8J%9$&ebRsrr#AqI=a{3jn?di&CH?juqq&I=5;M)I56&p!WlysuCul8j8U zNLgW-8$pdqWab%0S{QLMajy~y`rxpBy$W!Zy0!{;W;0*T=;o#T(n=O={!FeqobkjIKfd{+57t%%ON7j!<4!r}#RfeWqkjj8AaH5M4~>$bfl^^pt%R$o zf(Ij6`8U7)=IQ1k^N=|=V~1GF(Y*12L0g}b%DFrX_ouNq%`*kUw3qx$C&4Fn{b+ zfhOGmZtV`DrfJ0q+nbYtB@DK9%fI^M{y40aE(!XZDO>?h#d`NNd0g3yVqON^yqV_d zH|kjEk1B%AwX~d&8P*2)!8>QZfAh-PfLRMBE)6B+cOGs2?yF~;hZqx(i7N$ANP(6f z)uX|QC55+khba1jYrkF=UBMtkYG&?%Gcv3OeDKz}A;trNIqw|kw~v$tC@fh?)3nCN z+ZO56B?JBH3BXHLSSMH??b;9~@jX0|pH<1G2-rHroyS`qmd$7Yee2xPwQ~pxXkAUB zbY1ZN4BD?(KvC5|{&)72`T|1Zm zz3*Rm`#e^F+<+ox3~uYvUMPR@2v5)I2s4HenGsA0%9YHBh}6``qHB-wxRb(>cH z`+R}L%K-h?z70=63{D_nn)BdAoM8c}@OB@d`(a_SC1%)NcgPz}S{zjY?ZlY-L8sC8 zpiU$?veke6n|oUY4x&glP;+KBi*Rd?xmS6_PBt-4KjHCm>Z4B>urh;X>Pv?AFVjyy zJbUxvG8enUKxJtjmbb_Jn_EwBJ+&PL4~Rsfsk>z&0!D<8)ZQ&{GH<+GdXUwxm*)Tg zn9RXUtl$!Fox}C>%b6DhDLlLP^!fT8Bu}%LluU&h-<4)kD4nFMvQl4L?Fqmu!&kFv z|B8C1xt7N777kM2Q1Q*9gOO#?SO{kZ^!~Ngfg>jf!D_r>8o57z_Uolq6MU3rN|=n< zIThgh&>X}Ztc&6vQFoYAwAvw!}pJ6mztco_(46>Mf^>e#n8$j_;( z{Xjd?tF99_`tnLF-qpCb{yQTUn(|s&oKV|U)SL*pcO*uwMJHH)TR{J`ukFahgN`<7 z>dl{hx4GN5naX|Z{zzjBJDJu1+L6Q4(yyu=;BDydHPuXaF-uT`8bumDJCJ|<+l?3o zC9)!Fni4jRZg5X-RbJ2b0z`9MxCG7wVrA`3f%cd}fA#9u%b!nR=0cJxR?J|x;P~LJ z)n(Se6pi+kZ{6D|tJ(j?5i6BQyb~TsZ6D#D0`vsnwb0dyh9*CXWJUqF6B%Fuk2bKm z8;yp-JP3aM;>y_}NFe}KGcpP?VTSj6v|ler07xxW62b7mE^J_jYh-p3L?8w!0vd`R6p=~p0BQBBY;KE=2=Id|Q?ZEN~GMKx% zYwA>`SzZ9{W!U+WZCn4Um|CJER7@b%oxJHU`=$;k0aSng);|uRxSjnvrB80(# zW#4QZ{OBwjn8)hZCrKx4cZfcSAhE$8S;3Y&|X~wl&~|#-N)O91w=Av zap~;v`sHB&1aOBsz%Vy=OA_sV_3KrT(qt6d2pCL+)U-6X$X8LEmhP74*&nQelB~x)nVNdaK_xQNG7@D^C(yi42Lj{Yt3vA{^$+hru`*vWUSMW;p!+NlEkLN(GU zW1qj{w-+ZfKs$s1&AU2zx6vu&bE_NB_yR3DZ?}g88}I+lCtm$K$%`OPiAI)a0N~8Y z&^YVZr;+UBuW$PpV^3n4x*PaXa03BW7b zsqL@Lln7=K3$!6?wWeMyQfi@NM6F&J-CyTJIXTV`LK*s((=7M{MDBa4n5~= z;DEUixoRnb2K{rLWZH6juZy;hc2&3$(@Zh}x)tcgQ@>>ZT2BJhIlhY&>dgcx*ZgB{ znfquxPlp`gL^%N8)f=$5>2VT+AZBjAnX}K+ab>Ma>clO^(PXqu`PDXBqh>QCIa&HQ z^%lrGhOl*#S3P@Je=B_zO^v7omtp1Ke0ul64n{PH5Dr026&hX5!4L-35ym!E)So(z zX4j%Vz|=jBPL08EcbKz#AZ1b~2U)4XeKo@eZ{a5&T)J{rLfi}RLi0mg{rcg~FFxH| zKSUWYCNpvMl9>bq1-nc+CiSadFHv2sPoWG4G|iW*X2APzudb~Kv;@f**;n@u3kL#9 zg95~4t|F`&=b8RY%%J<#6M&a2Ql{NBp#dVIs#%^zHDfUb0v>Pr_Q6<52*x^k=jyqY z3{W%|iTW8B&dJ-ke{cKsQU|bbR4YpZz(NX(DFbY2sbGBjxcJTIPq+8fWk4Y?;@T2_ zbp68jZZ2O~21l@ICU8{%mOy!>^lji99b1O<7eCK>8tHO|xxs2GqL45sO(HKJ0RQb@ z-QJh88irbxd4VdE$Uv6TqX2Jf5@^O3sHq3Hh8O2l{aX{m-U9UXnDIpUgSNQ8HH0t? zRcA`M$EW#rW(Eka#HHtTOMn(VoEPL0EdIk6{tBK9Z%n^S=%y|-xT0}`ZOsyR_jB=? zdDc$Pq2?Zq;fSM2XY0I}>M}tgj3MLf{6GE0w}%u8$`yf_4QY0kQlJ^&aHZFF{-34? zt(fTajXaY;ZsTZUzq*E>ezf-CTWd=w20_%wW$F3YetT>E>!;(f@S!sgmWm=;^kE1> zh*3)|tsZ{sSHE7ovf3m}poxYd5hlml3f{W7G9a~BMxtjs`En!*f_)|-yD2-e>aA+;|Q1zX?!*t{qUW0H!twn3+V?dVCm=%2cdlTBvu+;D()5^XDzX~9@<8;>1fx_@jG;On;Ue08${ zr|THZMh$e{$>{`QbjN=iS07LAJTrgDGzVxvJYljunvZ}R{y@#Y)WR%jU;v%WIQB@g z=S#o*>hb6I4#u=%GK@y;jHG(@r3%%ah3x+7DwjrNV5FNjuA^QQ1;Mkx0mcz4jQ1~R zfB%P_++W(euwA9PASvM1*`8IK;vfb)zIEW}5G9U#Jhcr7jrQhpUPXJyOX6anC+Zra)$z3g^zVY3(CnMDC^~J1Q#Bt% zC!e?>lic)eX2Oy&odYn(|7Z5!5%nowew2J9-*89cEscY%w{L6T1@(u30ATrcs#?;HaKB!f0#xOHa_9P>;2?kpLgx7B?en>p zOb+7porA|)u_Q!?)=hxQQmfDX>IuN>*qv~mzSD__V`(ILX>i8g7~ecxHy$#Gt-OA5 z^~yOQR=YXiv323cwD*I9h3-oc1H`KGxg!pKl!iVHwOqG5FyR-n;zq zjipOV@Sq%0u=lGnc}yRxfzN-Is%j@1ajslfeQXwsesn8Lyog2BllAahqUGcykB zhw;wl=-xixULSclPb`H z{qb2f`CK%=paVeMy3Iyfwx?S8Z`JPeRS}QV<^vp&pRj;aRd%l2ig9R(b3nCgb4$(D z(t}NDfoA+hE2n=Dh`s)r(l)C)08G_vZ@t-xkqyhR{Kfs9TaRpP1Mm=*UQ??1lY27- z;3qtS7JchTLS6?YIRV5@>|hu{V=Rau5*TdiFefXijOPdV{>|YBZ=G49v5gNi8Gu)w zAMmd~-?+bleaD!f0t(eAO@9jR*UOwvn!J-b!K<0K0RS?i=$Fso`lYk11rb38cOP%; z7dR+^fT-@E5VHDAFfS&!Up)bMafH>{Nt2;65S&DU6UpMRCsDbafzaUtOUH zHZb5RWxi^`Qbh?;_B^C#t^Il#dg`h|%z;&~J;Ln=#n%tEw)c%V*H|tEyKqMS?1z_b zT*NX%M@3+`nVAA$;Sd6dRgJ*p)bdZ86Qq7Z_E~=qVOlQP)`W#Kwe&H4`6u@B#IJ;VZ_3GD4 zPs*BJpig@l|2FI3BXJ4 z&ZZt%qyJjv>keX4g8}QC{&ami8=i@ZjO@mh^JkLre=TZuH*#kuPQ^!3RP(_79_`ml z)lYH?T z?#KWF86h&1-Pn(zr;9-UNg<2FqLQPs)p7_)bbme?eY^4e{?_QulRXP(oB}fs^MMeP zQ!@xm#0+l0SwjEqP%w~zCRxA0Y_@I%>uG`-r_-QjE7McJbP-6MhyMMD2frAAo@EfP z`SyxC5_64@Fwjdr2g!0K zp%}{;6-ELPRUF2vtN4o_uKnQcwM-8PItItV>KBhTe*XE^c7ZTNjFxfeMc4g$8T6kH z8}h2-Ob&t(ER#S^c@``}7=du}>ZJgv5$8)!o^9>yp@_-wi({#iD(%GjnfoR{PXJ!} z3`*`*<(?+ZDu4vz(m6m051wq67SxGMmxH}?g}g)-zywugW;bn|LQ>tf*Q@nw&IyrF z`-P*@rPq~=ls?+FS0uo}1+fpq!!3OJ_2$#(W21o+72F9*W{1BX;Gz-DlFynYJ^_tt>2RUriFNO!n<8DmkDW-1~g z0idWc%Q$DJu@4mQUkM++b$&&3Ag*r6mbS6>IuM0 zZBV7?WaPxo>~L4Wz$hcKA?+4;@GNfaMWqa`IT!!VkFI1Ta!URt&dhGcEDkbCQ@~Z> zr=b}AyWFq(bm+#WY);M&F^Cfzv6DNDVCiz}PCy`5!gImzo)o{myRkJ2PD=tAqFklJ zfAEvbH!pFn7{JM#nVi(wK#5>E07!C>5v&Sor6dJG zU8YTT^3(*rGLbN+NrihuU+iIL_hcc)QH` z#G~3ouP@Zg38#EX3;&Qs{GlSg)zm7CXWrh{joNOlwF(H6#D+#0(7b>;D}XAMmvfcd zKuz)kPh>@tED5e}omhZIRaTo0MMMsiMVAs3@?9Ey`mFrL-MEWkNf{HwRon^CG4f7KKPBbxGr|cPAtE{B-zB##(o1`NnonG9HJW3q9#o&37eR6m6(?@YH!+|S;JIquo>i{RACMdx?g}2rPj2`~?s|9`<(SH-G zk(vPvz|6BjfIGS8Kvv?%AFgS+AM!9(-W%iIlTjp;fRjW`t>MO_83yZJ7JCBlvP)=s z)G4L7Q^5WhM)>yOI_Jy80vo?|`CJBMP_F#le9E4Dvi0j$SHD5~)jA;(oXJz~(mTm> zFgVQI!C*1w0@zpl{{F$8r#oX03KFtlhu7BfKl|a8At3`oqi^Yo&8QV}l#)#`e=GWY z^pAzn*o$0BW)7lii7*jj5s4TqDub^cY#z|+{q^xd5uC!r%AJTLVYWJxtMO+<_up1g zpQ{Y7uFbsHjgD@b>{RW)&J6Y$R^W}DS6e=GB8mr0*%Qqb8ofA3cg&qFekC{fi@&fL z9nlf_22-&Be%$*xy~pObKeeU;xb@%m@9cc;^;k*z9gQm}*O7C?AO^@&Byc)-nV4AI zT^K>)zJAY&mj1zgSQGA#EP zqhCjC-qy*vDGX3+7hB5n61NOGAiQ_|vKAu}i6l>-@9m5LfdU~*oFP%>>OA9az0jg3 z054UNB_pUisf!tbhzUXYt-bXi9fAH?oxgi1>L9MF)5g<@u z3#XNyy^ams4K`(WA~u7Kjh`Rd)&cH5e!jJP5Zy(jYBf~2#HY0Qv;XpBak+goG4bKcuUBiFOc ziaS4rDJRf*pNP+dLe#^CP04a93fdvgFjp}XvuKRGu)&uP)<5}X&+;W;K+a~-T>)sc zBX+`U5A9R~HudAD?NH_>dDjI9X3NMjKop1sV+Se2mIXh2clCp-O9S0r&7IxMvM1}s z&wu@FuRsCXFQEiQq+nnLlCMB2XH=m#eKc#oPI@Wq(%=*do-y9LnlH04$HL>@SnobM zEUh{$4esd&>##kd9^du^;N>c^lp3hy&hC^<+sFYIZ13UG`i@I3LLLv_y?$YJI9=@T zjnevcs{O_WHUR8I$}n;x2bl;LFv)3d*+9{J4#}A1lTwB zXLg385($xmAmlkb#Q4$mm7jh5cBcDF0j{nV4&vZ%zIeVf267xm=RAw1Qv|OSq})p~ z`t^cL06+j%81G-ZBq+h|Ven{UcVh<>5FKV(75*zZ8&$3?{ptz8ivuzzAt8b_2!vq3 zNb&Xk4dY=EoyjhpTfVx6LH~yK>t(lGb-~2o0bUB9pJ{oq_9~U&=a5``s!Kv^Dnmw%FO}hG>t37-ff4qzdnLU zAIr3lKD!)o*s_CD>4410j2vKcqEgS~VF|5KY4cRUPMkx z9HTRY(~BZYA+NRnMUVdb^@8OjcPB#@@$MBqzmf}kgcLOR_Q~#kgfmFEKGN&i;(ojJ z1mGn%Uefs1p+L^UB#6M{O>FO%(U}Q>%J*)Z%?S-WIYRy1uYN5CMBcH^a3Uvmf-{9o znfDd99v$3%@bu6al8;B@C9%t^*-yWJ@$3-dC9Dj@OhPW?3~ThhS_M%bhyGhwVNMx$ z3ZN_kA14GQ$>zu%(NR$N=E?Ktaq#8+O$}$F2L*^pM2KLfFqh={!!%{?*#N;t*53|! z?T`Tm8Vkr7v^P=bchUa@hq_~dVq{+I7n%tKrn-det<@wOPc>$};O(8S0&3+3_^fEBmG10Q(GYFZr#2hGygoo<4$DnvsmlQ{$s%<# z3mjljGb0d7^nBM=|MGX=K95vzUI~-U(ir{`xc`Kd`&4~^Bc7+u0C$1`3QEI2)nJm2 zBb(;r%K?A-!^<}>uPz65M1)}3(!HlUU)>!aD#Bo(E>)qdu@*=UjeR+`UoTX5s0aWc zj?D1xTWeH~iO3~;wtIMgT@@f^fXvL9NT}zVJpp(H%aC0B8OlsdjIm*7jQh`aRhG>? zu-&+Pc5Mkc!Q(VUNPD+`8~b%K0)Q0Bo5Fj>r? z-#I_{lMj|w0>qt}K{6_;dBzm48&U-i2B_1iht3xbw9J(&9KF@svd-qgiXu&WxLYey(dg)EZoDp3vP0o^cN2XwjfVaLz_py}RYVxV5p1VFXj{2TJf43)?>8 zI6cYZk2(n@^CdTSo8SXx^WYv}K4h#0eE(+t_Qfp29x#feBZNDT4?p|5+&+Y+d}>io?tk-YB*K(uQfxAu>&@CGfD3Cu=u zHIJW3378rfJ>lHyq!L^?fK7_ci-bedF$2pYwF;Q09Mr60e4^-QkN zLIDd)Pj-q=Ztv_Ah{Q$#Aa>{O5!kPOz1YcznH)aM!3BQ!?zzmyLdto*Q|QJXMhXbP z>>x8&w#>U=Vzn9Z6^w>oX0 zN)=$7G`$P85BofvJu@c!w|8sd?vvhTXPl35E1H^W=!PQH|F-;^e3q7Awj0pWa2YDm z5phSu18Pl@wEaCh?4p)(No^{`pz7D9SDAn=8MBia)L4Xq#8@b05B3KC;&)H>O|1Tj zq%55r5A^|p8O#4;IrgkPZ^2>SxTMV6h=79~g7k?Im^E#}I-Zc3n9MS0rvCO>{Mm<> zF0Eu*9!AlT@RO1K`u5I~J$N91tt^9!Si)lFJs|7X=`WP&;8pWNqXU^?h49{$a|0@4 zj2?zxeDm-SC;?)21%pK-TI_dQPXJzG^AVz|y5%{=5+TDV;>l(ldw?)dE(N`DaljF5 z#%Wr!U;R2+J!6&`l4B_!6r(W{fhmfV8Sbo?pM0~u?|JD#h$N1$tmHqudG^W*Xj}<) zD?@zeR{2@$fED&CPwWUsgm%^cm@4BdZD$dMa8TOg=i{9?xczX;!g6U|r^9r$94&+Y znef*MOrPh^*rWthQ@pjhqoeqrF?E@sf10cNuaqYfmH$-Le=hl-MXS`QLi?Og=5#p# zule?pSz`6yctq2HrwsdNW(F!n{_#`BPUu**PNfahdLEyc{M053%{AfC@=>G)WB=rU7qH^m`TX2~NJ|H)48OX!R{}emT5z>J+AL6J-5ut@r%=vCf-ne5$;qfLf+zIZGV({)ma!%7Z)-?eg74PMz_ioAPkgq=7qrd#*!H%CP5F`tJGHN~B z3V220((YIeD$^bVImA7{13(OCBtDZof(YQ0yv>FoaF+3-cUHcC<9x6~^}_Oeqx3Hx zZr^$AV}m-uG0cKT?Mr0+da0VM!wG5@7;8hkdwFe%Ox%sb46I8L%0TvzjyP($8Tq=j3q_@VwvjyUKK7qp^XMyk%Nuid$lfFs<c97TkP^r!uPz08WyD8;b5iR0S&?DCl<(ZU=E zgZoc+4;5+XM6DkEW zVg=li5Cvn4Ft1F^U*+lnq0{1%40p*no>CV zMRDTMkmA@x(%E}z$itBb+1axXwhnhnMi_#Fs=+NCdmtsaK*-sd64jZE?Y3ck7u6-f zdJBh5&}Y};9kb{kO?AJ8K09#9omo@}C* z+D(#Up6;k_z!9;;Q_Oj;8EEyG$wyGR_fQKPbd6%`SCYmLd?sYN+tuM(v(!As$iPY1 zB}Q-tlQY~w#6r+Q{n=su*T3HxiHwcBJ8W~r;0@-*>MbG$C!gEtlK&(fj1#{ACkF#O z=@m>qECwjLJCHGRSuVSMbS3-2jf*R+<>=5^bhv!$$>taL#)m+xG<-cM>(`4fVRAPf zNTH=WGQ!)J&MdRCMUbpNKiu5M*bxDdVA@w>djjxsS38fIi5OuFeDin%!<9ly_IUlm zxpM>L1XDOeVUk7w`z_b67X{WS<#$B*;_kuSr+cLjioyp(=W_bVdl#>*Q3lOK0UH%# z5irvPK>=xau(I(ZH*zOSEFd{3*X@8=M0Zlr{}CdDBTPaUjWa5!ps;m_hnu@4GDsey z1qqY{OUVk!jhq+)VrK>e^YUD$+!&`LU^~JO)J5NEK}ZWeVhUe2^7O`0bT)2m>QxbS z75qMv`87YL%4b#bv+(yjyQW^`q8HryOXqY0j{cLIJKOZH<^a=uo9-8r21}cnv^hYB zy>v4r&`|+R{dM)_w2GNya`q~E!-?Q(@vJs#@`z)P)+2nGav z`*^gkI8-IF<(zI_9)yUTq3RS^p>8gHd81#aI-_-6_M>Cp@bk|$p6r&Itwsw&&enqe z?A>!W&*5A^ARHE@2jO9UFgnaaO75)fM2HkHh7y#`I66flB{z1k1vXhwCZ-colw%>t zv23RkdI~-$@r&PnxpP=X7b0;-kQ_+)U`9|OuoECgoQ9ldefQhyf1VBSs$|@l#iK0_ zfVL%QJ?hxa5=K?~pDIbu#pu)eejJh2H5G;XcYaHsWYBZ|@b3(;KK55oJqdj5@2#Ge zr$$GJ<}{I}-HIBq(A30vmweXDlAJsh|JkXE-y4QNs;ZYDmLxzc}wc(fGePkoe>^y?v*ZPJ``L!yR^HCCFDD{oM@vhalrF@PcuX<^e)HwCedU9w?#5P}8`4icICp6U7=u*d z9KM(@%~sawkv22LK#yo&13$ zbx(GHTq4^n>90P&f1rW0d|VWREK`jsO{tykRYCLmX&RkL%Id}(DM)7;m|9+qGaf;0 zug?j+m^c4AQU1^Zlb_a~S!wzw0Rydf;AF5EM5ja5e=vN%NR~5y?BPVy0`uk{PC%X~ z);)9lf;wRWlUK!QL!d()C;0%~#?FF9!6aR{oetLOAW>_EzyWtlPp+z*( zG8t25lw>wZ+iW!xNRvHDR+NGXPHHjn0_1#hq)%RZ8X2nkGtH>PRKdW_MYxO#W@2&E zfgnU&Ucpa3IJX*%wIIS+eP8)+zI=K}C|H!Ztt_&7!eHtk>4`$Wy4%yi06ddWjxEnP zmJr9eWn4P5ET$~tS$_Z7#>hYc(X0}`rZa&vc@@Fl^tGI(E^}{I4fjnW03KPR7zpHG z16$HXfJ}v)h}29|_0HAZ4hf?SUq2r09#TM-MLVC#`&X94p@uMky0M#7HKU$!_N%kw zN-vF!h+xD{>{Ys)xfz^@+^ICgfX#@Xf3dkWrsyKj%=fRJ#gE=!IllrZM5^Ev@9>EWyA!O^Ps8SvD&5^+g%^>Oomc@? zgDEV+qZ-_cas)PYHKI~6LjDi`<(>WIcgMU;Aq&E>97`aAk%J*jIVcz#CNX1JlEEA1O*CHsqkek%y; z=xX$h-j=O1tv1Mg^=%+*(P8AM?ZhEhk=GrMhInOnz5T{ zABMa?#4>~8Jm;3 zM-c#SutsC(B`%-ApS*we>MBXG7l3GCH)enN>Elg<3d#~f5Kz>Th19GW?`hA+^cbpWw1KrGLBrrL6mPEj|+kdM=yzuh)h9-%t=()&B>Hq8JYqC z=j&s80`SLPpluaT;qC?}mIRWR2+W>`jeR`b-ZumRt!Ck^3ujkya8(h}szB3N6!t2h zezowMa1ds9bC@-Qx=sWNL8|gzhV2nP|7K^m1ZM-&I0$}jNq+R<`7;BsArk=R?U>Z6 zlFe6Gv`QC_hC%zp`b@9|E-EJ*z?lVjvAWxi6+7D)CVqRJrm_}xDa4E}=cW~?R z{!W=I=W0gqAe4wVRU^z?nQbBsF;65P+%1Q;OkmLDL)GBY37*h@Q_-E9B+&S~*J9HS z+-%qW+dh)iwd(IC2QPXz{lxG4A3p@@c5mji3_8%h`_4>i_S50P-p*&BIq4ASeDo6* z;xk}}E;y^dlU|!GZ$i4Ie4S`AFH93qih`imh@l;`*{EF&)V|hS4^z+O3!Mo?L ztSx6`LKKb0KKSI;*479#M4>RpARojs1`(!oUZh?l)UTtra&>g4J?UUzb%-k$&IB^k zGP-Q;jJ6M;3@|53$qXX6cj`E^`Pe-H_@lnkRLVt$SJww47+Jbdw6h@1oFEQS;L)=^ zRTBoR$b((Jh&-!mul1#cS~e5?>epny1rLaDMB}Kzjfuem4kYDR74U$KJ$&-j`rcTh z8p=_Q;_|tbKmGA#aSSt%!!35&?~WJPZ!dXSJ$5iL!EP}oXC;s9<{)P>u*1w$H5Mg< zU^vBpF~8VEXf(+QZqbIx zrL;vmn|qq?s%o>t%`AizYqcjMAI(ZkHt3hW)&Z@+r>^bj$JGGg?M z@|06(sYgj5^kUk6&D(u01Wrx7c7KQ&moJ7hL(ZUXc39}crw640$lTZ+&dJ}(EH#5Y zxndw)0G#N}l$^b31ORDt3AJ9?sL36z9N7Sv8XO>2U~i148+&FhWNhObSJ#F)Vo`gH zOjskes+0ZdSG^S!NdoTzC$sb)0~`h!1MBkg_r8W7?NJUaOV5(tz^JTj?rEj zI4N~HI}MmN)H|V5=Mi_F+(KmW?;>_)r+OG5HPMLcF$j&3h|Jk|`R*2e{ncX`tT1x| z{mCZW-KU^-8?R5a*DYAs5%YL^8rY{jf*jpCuD6*6(hmZ#}XC0TK+NMzM@c1agBW84#HhDoZTK%O2)0To;%(>5f{LO?3uc z^=UAW;5<9d4)M;l!8=#hmc#^PCcCBm?(2sehbS0YH|N|8?)7{|@2TCdxdedW>`gjM zZRur-r5rb}tPRO>3FiD{b9ZYm;W{yQLz2?DGdqZ#*dVFT462&*Z=Pq+8%6*?;>^xK zD*UHg1t?lR>g3MOC>-BDEXGmPAkZ^Ix_%jCDI7)HatnZ_#+drmFC;w)F(d;CRLL}< z6*cex>m&U7*0X)hA_5UI()X@k{P^a|06+nhIJ1;#Q3PhH2YD0ToLJQynId%Zb!>L; z(La@5138f;14J0y+|50WUZou0JkQ2uR2q&Y|7X9xw}Ug0@)*_KtB|0?z|Gpmj+l1o zp?2z1+nv1y(P~ggPMk0WO=`fk*khs?nH~$K1s?xC)p}W^j6dR?p31L%vD=j9{SS6~ znb&-hKjB#JG5>v@mnX1@44^e2&Ru(-dM2R_@x8sBxMi2g)7_^!9E%t&W1{p(VNSJw zbFc&lsaj!S4{QJQ7q<>98ygj7B%Dd+>IyS*a%XdZ&Ee!m3-SHY;{MZ~A5L!SRo7uV zvi+oef@{VI4wP71!h2T-H?OSaq(Rs?+t>V8Up;xU56V#$+&}_ncQ-Of6{y@_Li*J` zcOrEFnqz?gAc%|m&C9&DBpIWOcBpdq@j?(*Y&0>RuoTroP5^^gP&l^t@o0UA!qOnm1@Y}`7lVU=Oe*PPO&QQ!e0ud_zdGlu zWGLwbBXVM}2suLop6=q)J6ngI7tUt36zH8R7v8y&4G5WGzz|ng4Z>zxo#!TQI;WU6 zIc;5V(OB>zYr>AAAYJ`aiKYq|ur!{iQ56&^FT$C-8~Dxbr)7RFx)70wxVy%fS^?=V zYMy77xk$)m0WOSa*A1@1rFkCYi0u4k7A z*Dp(wW5!t49wgj!qP!mf?$`7t^GK#JG8ZNGAS~`aig>h-U)|o=El4S_@G$3_7teh3 zR+c-&kTC*0FvX&U~dMES#MRNL>H`Y)qDniZq3h37o)%av9F7 zkMVCld${LIW6vN#L=q}WPsL0k%oANlE4*n6sn_TYlx!3yh;3?epj*)G_$e+BHgxqN z_4M&fUhj(pb@j)^vH1Ro_jBt0Swo?k3;K*gY--Hcy99D(o6Vp3?mQ0@O>HFVq(&qr z;7s6p5kXbWjF_Ax(s0j~{@HK7*;1B#P>c`CvSbN~JTsgfjaIo0Q07|toZ{5&Or0CNe(oaz6%B+))N+(9fNl7N?xvm2rh z1s}XUc>Chn5XWTZl!m|Ru{dRMA z>>y^?kjsy*o%!fmSQU^0n#KmmK_qM-b9b;$u!XnKzb`^mO@(h#t~>#5m_k>x_+Tzo zXn!3ovap%Cxo}8t0g;!cC1qbe*#5)o8E*S5|U&kT#tfKCC-628Zht1F|-vytqkcl!D|~}ru5=g_`~d<&y_65 zm??+=Ftg^sjIrdw$}XMxC%^o1OcE&@X+Z*4xRN_*@&Gl5&B+3rP1nW?sen#+TAT9> z?kTnp6QhD!TR~)1nYQYv2)T}RiShAu{@(R-11jB0;&5o;7hgWzQbdND6SKj~am+(+ z3EHm?`Ue!y1iezsc6G?R`nVB|O@&y}}|%2*f&i=lZgjU^n695X#d2MZSH8UqdG?n4<*2wNX*L%6lq%J zU!(u_Sn%AEeA5~BsW)`a`Jhu0_QnS$mHaVjL>xtvuvka*k`?sHY{SWJc+Gu)*ZO)+ zrH?wgGtjbr^6sZuedwtpmoz+6bHmN9z$7}rCpp3Gz=0IwN9`=R>6b^7V|Jn}n*2sW zo@a+eap;3@H^!fSyK959rKK&$@G5u9tFSq$i%q8h?x$)=o&HqNoQ@{ZAI%;yuyv6Y zIhJ$LLtP@gcU9iGw6e^lYoU-bEdS=KttUIMAW_MU@TTzfs_X*iMh0-xL z+<&@Wl7LxkeD(aw`Bemh0I@7X2%e5J zR>opA$A=e}Ke{02Gl;@rPU^4*L0Jb%%~qXXt~Id$`X6!UwA_DAY;a=KW>BLPw+MGP z;t{gjPj)xs%BOd?w+oIGLYBKlnp@>*W2AY5xox38Q^&@Kr{vo0@y*VY)T$ouI=**H z{=7hxvM3+m`0D-(VeU8TAUfguv?ya?ZpJdTQoWkF-i8CTv%S*Z;AS|iWf#lSzs=kE z0P|=}-NI^MN~>Fy9-vl)JV5gOnEiLZ`0_b83|7jbOg81_c3@Ts=?cR|46XL)a_q!P6}TqJcP=ff{WcVKT+?M#8s#%D&n|$*M%1mGF4W z_r~fZ3=G)by}INDG6umb7Y3THBDM!K`}KxyqnUAA2dSZSs9<}9-`#q)AK4{?Yfi;` z*UrCxb$OLQg|jJKNhVf?sfS*TD=n5O9u1X_Km*5OtkZ`8RA$~pWH5CjVt~!bc<^+$ zyuV%C--=K7-Ls_#Dm7Lbp{BUsHD#||)tze%PlN0y=P#nl+`TgPX@D0^ySQ|xIV;#V zh5l)7A^DgBe>Z@(bK^-G@rzXSUo4!4Ufb;jG5wLZ*(u(s`{(|5?(3PI4VZJPuTwPI z9AJ7LV0vhHYT5*I^8s2N0H1PAty%|!?k{q{%~Lv9=3(Xb`uLN3VR1s!YaX+t5)=cT?%{X0pNvhSIY9?pet7N7%?q-e!OevrOkNG7PuJ4A=Rc=3 zR#4a2UKrmWeX&2eMyUJ96jfxCA7oa_MwWl`^ze|@et&PnvK5ygSx`+3p@YsREaNe314#%d;`2H@%Ie+pM{SzIC4hQe`I}n7BKPOl0g6Db@XE%f12kx z)eOKQ;C{r^FB2Ux6>!v{(asKYEaCt&o5yED#4w!?(A|FRc0oEwx(BVN3~VdakRQm} zKl$bDBIF~@6=ZHnVee3oRTdL72jx; z*4l~{BVvh^ee-BX10o)BzH|qzH)rm|8 zr0-ri|L&FHY6ep{nX@~CL8jg+On9p+*|GmierA=OnZNkUTZHpYq5_)xNOvcKMI~o~ zsFe7TbqbLdG$&@E~6*OQod8ZdLTF|IN#vYQJEv=X@$q zs?krz^!@1c8Row~(_j>}dHLcJKBM!1*V`dH?q$s0PW^bChA{Z)+8wjM{j@U%N0_>{ zUT4(@sI~j4elu4mHs5uMyP7fXgwSDXBD`Yt)bbVeY7!u=5Hcc)#$znqezg1511))I z4)SO)gIg7;m*N3VJ-2hLh4beA zczp-Ta7S|2rFKP!{M5F3JpuT;NC6T7NNk`A1Fp~%%y3ZP!P9LIgTgEjUAuVx+%kee z+{vApqbf_nWd`bc{et93 z+lXT6N-ko;&6r@qqxB$TTV9qfv*t&Zpic=4UJdi9I%AdZzr&1uZyX=4@9mX?FTZ() zFmz!K9Ak7x;0%*!)f!)-l6nx&ujMDqU77AK2B1kDG+nPu|2n3;BS8y4%2pUzhX(M> zFYaCcU&jG;#7qTu`waDW=ak{+AF5prkhEmeA3ZwHKX=%my)Ea;kccQ5OmQ6=+5F!0 z$S@d02g?$&GymwH|6(_>AS)BHsT=^RJh5Xc`X@>2J8dY~2?}dKtEDn=Vr?asB#1=> zb5p>X748mU3<)1y3GZDxvpj%OSq9p3`t0`3_8!d98v*rea!5G?WP~awyE?7Ar;@&d z!2k&ntDRfIwTqX?sdO$m+yLT!q;_HI`BY?&W^!=Y!Ae{}3gFXQ zo6is9xKx5=D6d~weZO}6OdWm*v9JMf1m;%6d3{xNZ-)SP0?TG^w)&=OOODr8v zby`nl>dM0ArWz%PQ`kPVr@QgN=6F8_rOfMbO4Ue$1zH;K<#Q{cXXmEhp+$1Sgw&dPrbFut04z z-x%z-qr1E_1@4vaC%2*isOv4;ye6(4s21f(SNC%>sRD-^EV{aDp65|9()|7|{mt$D zf(8z;+QcZS>N;Hp_v4RKpFZ|a2J~NG283prHcjR35FB8FnG$gb89=I$3>mP*_~5N@ z`)}AXIp#4&HDS$jt$E7gT8g4#3{jF47ydgN)S3($%az^%2V=3)QKm zbVSBZ#4kSIcs_RHAml^D53Zg6=z2C}K*0bfS8^j#h!c~UsS`ZK+Ip)KfW#KyAR=b8 zyiG92Ons5|mx#OigciVchj~X|Q5WAV{c=L{uff$VebN5f0d+MtMGg`1SGg{@Rz{Fi zDX?23n2kfr1Uq)c8`J*%-44TI4V6u*4Xu&T7q~6X6FA-D|6sS5`C}Kq{-dfZwBRG4 z)`yrRG%a8Xo}l*0&Y7^gYUI`lPK>T$s5ePfow8E%x3^qnCuE=w1FWMahzZ0{fr^0? zS~A#pxfovmZ~pC9+Zr@mbrUGe7!=$qjy;?2QQg~0IGaFxtNjFK9E2A11jsw#9Mlao z>vmvj9#!=-5>aWth}OGf)%)r~%i|4*aKXNb`1aW)B8J& zn7Zx;H$&^?u!F7A^!5zk&46%DaB;AcgB{`!0w=dAVN!QuHg^!iu_Sos%3uIM^RgU% zbAN9PM3EBa0x-`Z!HGrNj8hsn!4O^8`XSg&xI2eV%U^rv!`1H>6-N+nrG93n7zr6O&l{^?o z5vUnpqAE_VdLHlCW_J^aI+tOy>UR8on?Rb@<=z$)3%jL3KV3+0>Z?UncQZFd_T>3! zM+aX&+dZVbAaPXjdF)A`V?;hRC`s*!-Zc1CAc-ET&CXbgw6?_`GNE=jyQZe zd$!dGnizmOx1y)p1L!RVIOh+i)^oNtz_Gay>GFXHIq>|s4*&Yg&FwgF%CnHGI>3QN z%aUfAc(WjW`dbU8qfhu@r5N%BY{5Bcdwecw^ zGO{34n3ZAqm!Cg9G$;XtJc!1!dF3f+Z1Su6^~a9iCW`$@f455WC7<4J1X~LF_SIFy zkpQ4M&Q~(w!2D4Nf3z%8N^ znuEsm|HPN%Q?cHT$Jt2~z$f$oosJoH5*}!YJmf`b1qOH54>rqi=h;p{qVUSZfruRe zBu?DY($A{8=R!}Kr^yD6GXJ4z2}o1#pZkCl$j2x692m zX5|Q@v3zl7Z)AuHI0kthW0`1Q6qKB*!Y%6EuYT26R(4(;;H}GN2_uCT=J%dFR|bet zIXI&7u!8p;fIt2XQQgv~p9TR~obhA}+lM7_V6qJ3x33JTHz4TO^!_1s=BhZ@D82|F zHC6c7U4z_tc=&j8w{#9!mLZHF`khE(4pkSR~gm?;SePC_b|Zqg!zes`UOe6Nd)wvup+yQr#O{oV`)*UfnJuqQy!^@MqWN~6wLQ*DU5GPh ze3R$o100QzChub^IZxRgP8rkmrO4MnIVNxHoOj!S$~LG&q8FzT&+Lkc*zTw?U(9V5 zp{=HyYb-l8@|#{969g1o#F!k!L_%ONlW}-_DF5n{r-dvT2f&N*p_zsdrrbYI=<<0g zWRD|#z+Se5Ta|04+h;+zI4lMktiVw4(aq&6XP0u1!Gl@$Y6>w+~=Z5m9 zKfDqQ143}HY2ajcQ~P~VJ45KDY7h&q&?ij_I~w0F`Ylr<#W5OjRz@pLzIn2{RSa%D z**m0V;|u^3q|($Shmhp?>0Ou4c01oE_{lMQIJTwOP23W23UJ@4PA>2-N0U(2eYJD~ zANtqi$N$}g=`a6+kNewaM-Dol0(E13(GHnwHVI8{gGCj6Op{4aw^6_}!!|*Tj?Ao` z7%(+M%tQhfa3vnC#suJ$kHXr|fB$%6j04S_5Cmvh03C7I&dmpSF(xp#)-ZeL$v^Z7 z9c!i~>Z91z23aT%i=iN$Er0O-+J%*zqq2)qes5#v&PFj3n1P+0V3eG=-3Z1#`tR4A z4aY_qu$tlGnILSXx$^MNgRRoQOyo>XjXd@Z^qYDEgTQL@AFlqVz~(NVA4ZoQd+%Nz5Y7xy92A3q zEO0D~wr}_($?yc%uM-P@TShgD?f;I32&Ys3w~&DW%Gs0c{fz@W*eW&;V6p@fCK3?4 z*N%V{ESktVT4}!RP%zZ9&NP|D^m_Jps`fvr#d77}GG)oqf#}ojqOIK7))eGqgrIr; zV?oc+L07L&)BoLJ0xv&faPq%vwo5SS26$!W(+Ojqn-AcaN}6jA3QujbnGqD9_|Z^@ z158qfYtY}YfNEnWXPf#6B8P*=mhY7Lzxv(7!?3K*A+W)sskQL^sq%nljlWL*&b7>!2E)JX$8@;H5+uKzCy$Xchn_BoI^ zxful^hI;8k!TDwU@ZFV_5JM(~^O%>ve6ab=Gb;t;EJmEdJX1l2sGkJx*UT0-R~s-c zot2AchJjs;Ook8E_xB>Sw%(`$HMvg$eiHy3kiY>EMPOuj`uxyH+-(?ed2PUooEdI5 zYnIZlHyZudwoufBV8V%%@caN@-`^ftPT7*X57>VC@udqxPziPm>RIHqFfMOyjnq!7PVW%x7NllHv8u^`sjkdN>T|;0Z2MlUt4kUYD z0TYOs&CF%6i!=ZFlTFLcLI!F_RR><-VScK{!iyDUJLf&XL2AkIg2tvm0C~;vG z-+SlWQg9+b1_vtNZtSk_!vOM-$Qpa?)Ys?k^{cy2S(wQY8B&)>iV%=O6~x+N0Lr*phaj?d;wLptE2U3!gv@xdKp?d^e+mSSzwQ9iH?p4 zI9B&R_xH{}rF3$Fz4b1OIq3h--kjoYk=6BD0 z7!2?*4;WN;RdrP_Rh5-nW#-MiOT>*m7~JV@rYdJXs4Z&d?%aYALGrr65MYo_qq~_d zM?{XCqaIRgAq(gPR`Jmt{PN2?n~qjh;>X)F{`xOlJ4Y}9%sHxMIf4x)Vf>Tm{#7e9CoO2|-dZPrx-V|F0GtIBdA8jnh!WG*~v)&XSo}oclj_#_pJw-}x{A5bujJ;>P{EfBODiC^pO$ z0G%TB>QTiKfhV<53%YcB4(n^Eu;BCbIousDKXHB%ZqN+Uq2Kq3zDi?C(`ql$=kd3F z$Wvcb4d_A$e(dv81ppuLL-i;wsO{8rs?| zH%^CO>#mU*Ii!ziqk>l9(GC3k(VaC7jFlAQgQ&m%W_xc2^aSFAruB!J=bz zV~hv4;A?;;(UN2IY96hQECf(StB$6n7KORui1@ef4z~^&#TQQe?C=*4Z~f#hHWUho zb{L~E4qo7y)5ZW7H`dP6PmCpUp9?dK3$JNJ6@_kNdx|Ho_Gc^tQ523WsW25&p(epY zNp1j|$+mP}yegV8swMBu?GRo^@ozal^8znA&RgN9ffmW ztd95G$CuvE`5NshdlK?zmJ4y^k5d;&f@al#Ixvs{?(Y{65{(V1I@7;q62(et6Iwgm zC6Nl1GFE{!k&?y#`qQg7GX$)I9+dgY+lhid%Vd1dx;|XU!jz%^eq4|(lFgdQ3ekH` z3_rgwzj%Cm%qe1w;@{5rZ%+;mOZbsOeOU#wxZ18)$NV%mQbbinBOczuy&Ge-%7DXQ z-@iVjhd5Nh#$z91_@OP?KfIcm(aZu-0ozA-v9%|zaDa=`A3eI`3_dk=3e2dwswu9H z;lAjLk^0fJ!VLJ!v%_awd*(-^AxwYv_|8x6OA#!xhxWx_jwhWe_SW%fv%4CivgDQ253g|IR*!GPcw0|>IW*h zXYe8$IL>*8aZoGJEK&vXCiO1<)SlmP7NFK4B|AG)0R`fmsB_NgXzlfp{@?%jWH+e! zb%WxhME*y}gP-P4EDXbV%<|RT0<^so0zfQO6ScA7C->dY9^DDkJyogTU+>NS{A@Ng z%%V77NJ<;5=H2SZ*7;OHq=@sd7z)AX_cpwhjM4eg%dOpm5~hGES2Cd2e?Sl;=PX8g z%mm-PnpP-6h$F@A_2RR84gm^45fdK~x^e z`sEjH(_su_h<;R5)=%54C!=0-Cp2?kT3`~~5H@s?vg8tIvcSV@ z0n1+&zG1Ag{^!WdovY<;@eP+g5g&^rMEft`G1Z=4z|a-wxO^;SjpK8{VOCq1A0eV} z4$cV#Mp)YxV-buLV;L-rZv4-$zJGCqeT<^``M`Tw^qxEQp17)<#bNOLK>|fgB<24* z_G7j%c674jom z7%PjaP~iSe+#LInP@!dXPhV9t13YGB^xpl@hR+Wy000=`xEReMO2M1mBf2po)v7P< z-<~K&9yE%;24Wwy)9)@8Aa{htiZBPW0RtFKDkh&m~QIdvmfC?&~&Hky3^ z1)`kG+4s~Hd+5K%2tuE#(K0LMINTh(e!;y{894Y3fg^f%zl; zQ{J(D3@~@L>Rd7(q%DwkO_8$be0VUASCNl8pl2yCClAGJ055W(9)4461`tfeL~BAk zObRVV>b)q$&EU#n^KX9j&AW&oZa$JfQP+R)qYD{tr_U7RcQX_0&)2U`T^TJOk`w{~ zUVK>w@0|zM9e(lU=DizZ6`RF4mC;{bzI(NU3J4LhQUO{WgCjzr>b0^;96*c&Up%;@ zp+YpHd--UVay151gNK}EvXf&NpM}-%fHgI2Bm@>>apBBC!`_qfx z)VaiYbeR@VfIfYPfBx#}L3HBRydSGLp)k-C%1uy9RI&=1aU%Um!dr>X*2^K8uPdx_|{-ApNpXxd%UjIy1UxRDpcsiQ^#86Lm zJ`CtZo7%LV&qtVpLDkcq>bIoNe-Q1fMK!9R9WMwF!a* zB`I*-qrujQrye{D;=MW=Opf7@8E^pn=FeMuQyWb-5yHCiXOC|`*n|ca4oPynGOn+! z#ZYBdUu>_}NB@}L4o|tW&gcG5m+v3;8!!Ds0)WX4Bf}hRBVRku)Zl{nQYL@0FaB@BPhCelKR|1SRY4f~Fc%H)YJX z3gjA~XXEr=S|hMeb)B>R(ewXeFR{!)t!B{^tUZG}mw=WwmTCD9j466wqw$kLueCoP zjm>y#!p^$GrK=LOacPQ48sQ*XjD>Ro2_cRr>xY$;qdWhfzx~&#jw&fEX;oypRY_bp ztzhgX=7Lb++~$Du;!mA??wR9A$#e+@!{Ik1bjeybl zL%4tW)2k^kGmTC1PTFvwXSY}{<&+CbnXHaW9Ra|3P*Bch-W6#UdcoM?@!gG)MCW{< z|L*x)^O%-!&Oxk6{?>Rh8!&y@vo1}%3IIMGgfodIR=X||?K6onFr|4CYzbiGzlGwv1dPoi1xf zfRfXXmTZ-)gQYoBhb|{Th(V(uxLBeh%sVYaozWY8g&*C-FCN^PAee=j=yufKJ~^C? zq=dNwF$9TqPn9tehBn$0F#&6q(JBVG&WDMW5~IY4n@Fi@UMUJX;ztjQbzfCs3O_!q zMz6OK1wpK`3d+=(rvM<9%?hC;Ix7H*T?2f0^(jDY?xll6LO`8URal360GfcVm=SNb z4xDgW%g&8# zn@u!6x;^>nWA8$jk)za3w`rF@^%ldL^NO>U+U2Ytc;UD{p;jO;8k=7x4L}IdJ4c9# z3vRs6wZHjaf11f=&^4&5H}UkTq(=Yj3#+%8{?-Vrkx*p6W0HAdH8<<+OR@|wsUbjc0JKLw_cY%EjX8 zL~-I>yWq368q|d#;X2^Ms~-SRTIt{{SqBkGc8O9a4pRZv)5*?0cK7xLbckz(`|Q!W zcfAyBD-XCT5?=2^AWA1xf>fg#C8j}@<~$N zqoM#DO)RFg8&#=7MQ8YR{;~Z&B2=e=U&liMp@?~*Ph}8eG9ZlBqzhRNDewR^Y5dRRPR(5`!=hKGsm*H0f5WIK!+DZ-F|RBRC*y{ zz$Ka*$JQdwQ3`m^lEN{~17~;x$z=+1Oka2o?$I@ebn$09()yzj{fl*jR(r#b^CKC# zhd_OOX>I89H)%5Zy|4xV{U6Qk4QRlSqjP`vYo*(a2R* zROdnjJhqPT^|N=uMova+9&4`p55Kry018B_Ol!#~sUuS1hpaH4uZ?dR8~B*-r(WGU z*VmBjh-fgVM)4SJ9&q$#iogHu_q(sn~^JO8)l zfg}2YkEa2b$LGk8Pg z#siktH?UAGG&h|X48r+_fx%$TUB>$FeE3=F3EPSXgN9-%xBu>6zTFR~pb-ofR8#rE z$Vf)gC@|0jMs}sNW7~hv z%_gYV1)-d8gJ~?akO(ph1^!Mn{+$2Ldx&@tq)=f(Q==G3F(A&$nG|cUcf;4OXMg|O zx4(P7f9N(NyZ}p$wDRxQI^SIjz|_@6?N=01dy}Wv5Sj>apkG<}8~rr;GtkA6yNH=gS7( zTu8}9-6?ag?CJc>*9w{@95yas`EVR)5J`{EW+-Ov_MczP{_yOmDmH`BJB|JCQNnt659(axd#;k*4K zAUIH!q*KQTfR=iUQdx!z+=y4lbv(3z>u4monb@h0_|fAVV+V+t=-a*7L6xHRg!`dNGmVcf&Rb2N zA3zL{NZ<8zug!em6lGOW1s+Ec-#iO%52`2yXrASl4{trVr3ItL;2;8*T846Kn-5fC zJ`MUmX1iVXqh(=6Vo$@EdUOd;)CjXk$kf~)pT7Omi=)5)?UQ{@DmP}|JiD>Z&6O6r z^ z`cO|fv>o&5#d%w~iB3`M+UURi_S*wUpi5?q_g%}seAko zws*+}P}_hmpi>~?{w-{dor(pcxyiHFjD)HeQFg7rkZvL|ADY*IA#$|?fO$RPPDy~) zoCF{ak(gC@vokdr8>xt2Jh(kk6m_d+tw4;7F}8{VuKU3x_W>m0mj_%O3#bvX5}66U zdu2~w?ZB-=)ywS5N1LBN7(2oNYAVsUF1|t&T7At0jyGr6@y?C@-*YqW8~4y8GRbCG zodq!y&@saK@#~%Gnpo3Ez@#~YSJwAw5+0kZ9(YHXL-!2OQjFN1d8=KI?90Cdw) zQnREs#Qlu@-$Tjy5|Lv&)|Cf^3}_ok*Cm%39jMR@{<{!vUXDW9 zqmemT%#-UL=)b*%+Cg&>W}cGoz1v|7(0WbusXpB}=_?D(`+~Iru=I?pvr(!*=OQd< zjW3#>cp*qje}w{bn5zG`=Li4#%`9LN0wCTjIf*F@q#2B2f*kJROmP3)@cp8xL9|+_ z*#Ih`rjAgP!n>*p<53~3oZ<6(^2Pm4wId2ptbO-l`^A<`ONhEy(IJVQSt4q(v3yvy z1z-Q|9SsXiGC&U~=?G_dcz4r@K_$rO*{hv{8Dg_-q)ANL@^yPf0s7Prc(H~{o`6xI zN<#m;2RN8UxWYRfIX=9D0?z-e@dO9SuOKLGfAv;I-?!{J+q6*O= zZWQsyk0&?QpolQ276LGK4J56;IKkAD_p#^1h3>(v{Gl|#l0X3G{bUyOtM6YQ=-Qv2 zZI^DNG|B&R4t?5We~0Ef>IM56K5f%v2cD3*HEFBwopfF1v-_<@z})iOqPpeTbPRI9 z1AMCIBzbAGJoS3Empe2A0@p%_YbDqL?nWw)|IsVQpep?-?1$VxV8Jb?Qxi)~F zJ{9ITqfH>%#Q=jo;C$qxle_JA4%7o%2vTW9v5$@a@=sq?ZW3eyaU@$6Q_0^s(DGkD z3_$Zy>UR(D;SQ;2ueEt2~y(vtS0E__!VXnX2 zZyj14*K~VNqrRDU?Gv{c3I~n&{DCVZI+a-&XHoVKQ3g=am{fMcx67@2Rsq1LeZ;kP zJM-@_nyQ8n)VoLo03Kg`^TOarST%cm@774*2pB|iCh#f%_yHYNsGM`46F9AHt7wQJ zT4lO7!|(s{?udnnE5s&J{vZG0{<=r3>Mn631aOH1L`tr6E!LJ7-XF)Os{H_oo`#0I zklS;7H*ni{U`8{j`YHqq45&h#YO5*Ukah&At)Z1> z&oaq_wn%Axd3*bF&2P{)h2NS7qX**#1Pm4J3I@%YUv_Y32HDvQq^z8^R&dPgkG&2u zi`)-!%0k=Y4`*O}GDJLyg0(Eh8wR;qH6T&*8)MnlAD|tUU59Q~DFTq%QJB>$>X4=_ zWNB79z+8KPOZ~h}m8>4asvsB*G!aJ5;B_jKZ{E!Q@wcx_y=hb@Fjx(9K~kb6qmKsr z%*6oAj)jZExyu3<+6<7kXUo$ynofwo8#NkKMToKB|M8z5dOI9DR8fv9|A%k3_bLPh zRiXs|3KdZ^I)@PBDg?Ox=%2&!)a*`F!Wl-u=MU}{LUrEYo;-V9j8O(qP=yFURg=wL z9ZPiGHNdI~_|jDb8jzvt%*=T|tAcn0VEX{2i7N^V)ufQm?yorlkzSK<``~G=mWCh1 zF`i7S5G|4s76Czo(K#KBsrc@BxwTtT3erYW{_OFcF;Rd@YkWlOStO)3V&4rwecx)* zGA3^FEq(HL(O1+M*#N1m+#3b1Z)@U>psx^#abxJf<8NmR+hf@I#)L za?PJ33oQ%-<{$SwC#N?;vZ2*f06J3HLe^P>^3i9nQG33>bvKJqmvI}~Fjt-Y3 zx#*n+Gx!O|cmB(-zJ6CBz(hViNK9A1br2FC1w~(y&H>v&!;p#GA z(|kpEVnqD%%lqD@Yom!_v@^A@zMqwf8N?S8Mlw(d@1=@#g|}6<@0uUXy7D>)UqHO~ zB)y;zJib?`RSJV>8S(5jb%Y40b4dxb5n^k*37Gd3tO9^f{fL)710lpHQBVOd-X4}z z(!AP(+nbvs_@u><@qSo43QN`@v|1vrbB-VwjN+XG4Pv5*uplxczI|Rkd$q5{Cdop= z=l9ot^w5n3MY8#-GyF+K$P}sNu4gs>A<%jMHSe|h9Fyv+Arhfbi$-+;3Mfc{RTiG@ z9K4>%H*cm}hlmCx3w2VrA=XSD$@+R*D^^HS-4!qsKF_n)ee_>nf!zldpbykKqPBF> zjdf4PTyro7=x5$r04J%=ucpnVn7SvmXdhou!D7vyzDBLiIS2R3Q@x=js2fWfNvkdf zs0U`24xdP7g#qM-zJN*LMkJ?Ep6=;?|Ko1ZG1Nu1Ac2S=VJh@Oe@qM@ zvcNukSz++vH|@O-06~lA+xy?W znX!;y>UCVGvoff_XeMGSbFk~bOmwV{yl5NENOMXt+!*2R=ESiA;xzL6S6fjL>bkS! z*4LLf5As6|060hmwK#!Lb;&L<5{D&TZSR{nv&u{L`Mq^iP(Z|zNKnoW%$&tm=>J;h z2Y?t%)v3TKRAI0vYEJfM`0n{Ob!5c`9IYi%_aZTlAj`D59T&?Qs9lKe(2~9I!H$w``sP3*-(4XFBDAmGm$7=6m*2 zPc2Kj5Y8U+yatbW`89L)*L=j7#{jZ0gwUpWHyhr0G%J0NFlw*Ar5@w`rikv%I`tMP zL=+z;_x|oT-@cvVh(1aqbW@Xl#QL-SOsbuy1nzD1;6n}}6^Jm!07;)CLQH5qsSZFu zPn!sS`q}#3jRIk&g|EbY^K$FW0Rq6LR7;~esiN6J8==*4y|(ze$B)vLhC8Xg=m=E! z(ZidCMuA1>_EEWe2q;2?h^jc6E!-fuY6AZBPq$NfYQRUEgQY+m6ef7H9gixSIOlA= zz|9HPJcNv9>=lYSDZ&-{{~_cDoKq5^iXs4Gj7H}UBYyp-w=<4rWwkzbYp(iFzqq^R zAklI`I;oR$hLji=$#p*^`2q8N?Rb~G;8aV`>2>`E%;4^8M8GUksGuW?8H$&C<#sH- zd2_g56_t&gAFF7HQ4*Uj1N}?ItJ$t6U2NQZ+qosd$DMCFZ zn!KpI^~LJ+AAkMh0rZD^pBL2(aDla#W8$l2qOpq%zcBE$?uQU{0d0s8m=CkwT<9rX zsq{y3lMg2tp-}H!gdEVnJi>qZ^^-&0j52CP^T`N_NemRr8vss|>HpNc0JcC$zo3+e z)2=^HQl&K)ne7rT3aatu1V8_L{l=KFI*O1Z8~yRg&Y__+l!2pBkpxmz)oiuJUu*Q= z3QI5}lBG($Itt8e$06ugbptXAw zNQ~y3RuQv+S8un?jUq%CzI=So8^#JSdhaN(p31ABTW(gOK-YbZNS@G&VdcDP zdxyPxzza)YnJhz%3;5p);66`@jE5lQ!<=V(8)pR2AC1m*A)og&k=5y5FsJo&+)x?* z`oMX5f$ryAR582=dx7gB;#u#4+Bskd02m7OW)$`2ipsD5!|$KIEfHb?Q$i`AL_=kW zl+=8Go|We`Mk4Ry8*thtqu#rI&;_d27m~-yMdp6cE_`{Xr`05yVbYP zD|)~Qg@BQ!0_Rrw|7%|Ws0FDTDv%HYL8OT~5wU2A*8Aw*4aU--0sht7?J_`JlA42x z4s5@OYry}%MghR0)m~eP$F1#nFrC2_gpRyEx{pF&RRkzbV~g8RM|x5_KO`xW)hxQs z3q*`DIewd&ii?Is&xx0!6bAJd$*y;#DwXVPIA2-;wgG1JgdcnW z3&`5RT|o+?1blu^fBf+7STzJ^Zu9A@o!7gdL$Hd70>a>M9fVv~$MH$COqj1`c@R?b zzf@xgj?jp^H?cM;C}QHKRe1A`P(;g9Z_-2m*W%Et{&X9du4(a<#KlONOQ&T76|c7s zOh-m2;=LOaA2ISssgbg(68{^$?9eLfe`OK<;s)HsE=B;ncLK&3BNc_)KEjh%yGKE^ z7>%54j`;a~zviH3Bd3G;zv-{k#+srO2==VxWlQHF@*>}d(uelYqStpQNxAnR; zKr)&+rxT9}@bk|{6A6XFh)RmzfAe}T!2HNWRaIy)FfSu~2;=tE@#$*-Sg;a}iXw)X zgd)|!3Dz7Q+*%hJRpG}kw|1*oI|L=pSSIqJOx)Btv+Y#?@X;TyL<%FsnkGP{31YAi z1yQj#!}G19%8Ei{Y_q@qa${_W5maYTr(TR|Y_%H!n84cYV6`M%DfHh2gbOGIP5W+N zGBB0`6LC`b-GFc3mamVZ6=N$8HeC51e|GoI1PDQ4G*gkp?3a4mNDG5NG-dCU(3<+M zmYYj2+CERT^=)6%=#CG6QA9GwYg=~ZJoK5rc#rWfh_pyE(7;HGpccNWqPTI1(O1vj z9?JT+Zw_}V32tPBlazH!b#-=XKbb3c{ZMF4uC$S5?CrdbW+)TImyKqBR_E_2{kI~a z8J247*qTO6Hs~+`r5 z9P^+DgQOlgbw<9H{O^#xHb#>@8|hZ?3eAvCiuE;FUC_|#+M$^IF=|WT1s`)DBucdy zOGg%>UNu4wIC{s)|MfqCYORl%u>`aqT>6h*+7_tw;=s&W`) zYd^d@09;!4qoCFKP=gSnpgY!SdldkDqyTR&-AaJoJ0sxn^kpfdb*NXyTkE3{P#`w6 z|61p%ITJ$4R)ZD#zwSp-6d^>Y3xe-n*{hu+E!NE9#K$im+_*7zTb&?uuH=s7n*C<^BNZl>RYq&f3-u65#2Rsu#=oqc01w zRGMbd*B@gKyC9XJ53N?T_#$Va66L)aAG#=^nGNtV$AFH0#&#Iya{jeagbK1{AvdsN zJAm8le(rpiRmLROzgk$!%4Z4%K+?cbfiv}$uKoJkw=WM6WMmYhI2~D}%ylFTp#1U6 z!a~iTxsCU`6&Ap~4gBQctpb%V{LEypl&7z) z0?I&juFf=I1@o_W3{daa^*T=q;Y5>YH35T(V5GJ=!tKp57z3q(f4659L4m5&QXlO= zsRz!L?-9M~0YJS1Yx7B39zT(6QSf?en-BwD?JAE(i`!5ui zdEb;|+oWnDa0Er) zIElhqI6S_0TjR`$80F3O?ld46M4e@t-d)A%>ibAsg9?CxqBO0GthG9eQH*%Ijafh> z9c<+6;k{u}e`V`)-6nRYCfO4GrN>FgP6ENCB&NVO&$o^Oj2H>mJ%93OW4(YFv@VN_ zsPf~EmW5_LXM^>p6Z%-%F-%x6r$q?1cQ|{ob+}vkCvWyytf!t|X_z(wE?ig}z4Ka; zziR_n2*QA-8K6s@&H-^q&tEBrP?*S9F749H_9@X`sv7 z$LEoHi}m}(jiz~BW!_E>>~w)NvjSM~CYvNhTT*K;-fwwNQa@&54Q+{p+W7WA{o(r; zJ2B{l>Z(|&szqC<_KW47U-P}f^WPbK|2;Di7aL2MQPp+pQNX6hPadyrju5M(2$&oH z+h4Y#1HG0piNGx;N%nV;)p4zkoL-y$8)FpW!EIUdD4ZIxcUbKnrjF9CX|N_0!C0e% zt9@Mk3IG)lg(+hy1e(AS@zUYRi@hiX07ZO!?}lGZCPRM`X-7qa@CBT;eI>zugR&;NAb20%hpV>F1q-90?S z`d2UZ_M=C!o;b^_DG$^{e-fR`gAQ75kAvg29BXDAmTRH3Pjz6|TJ!$fuy1J0blSKv z&-ERX*uG{N@yiU>KZKtEaDM-uUe7L(RkXU2RPPhjJ;7NNkx7X!RveNAqdmKi~6vP9B0Zd9E6d&K+>asg7ctK$*}003av@VI|tT^RwX;Ge%e zq9Qg{-Ass+QD~G}`fn8gT!I~hEu^biKmnKrZ12wuUP-m`i^ppWt$52BT9H=A6$Ae4 zqsm30LEG=e0&n;5?aS>-M$sq?kM3;z=%F}+CkrRE0GLz62#8+D(sS;_*fQDv(`@JS ziM>Al9lngL#d#P_wD4;;_htRb)|7s|ijCzjln{ZbSff`ybT7)&s5Rj>^W{Y=%fnpE zhu{JzpKCbw*+f`atCu;Y_Mzi!XP$)S?VkWrKQgHOA%Oe$p6zE~YSi5-nGl%tdmb>z z&<~hqncjcjIgYv9C+3Jlxh~LLk|1{faMVlfV7V%fmP#d@}M|DE4(B!2JR* zb%95qQRsl8)*&?@Do{lHho9b`2)$|$E#2t%U+>Oh@)hoEo*F{qF+0oZxLygvEC3L| z8SZT0_QqHl37tlL_PUB~kl5%^$#n?_uY3id;be3ch>RK&+mH) zgazok{n_)kh=7S0l8?|C_mkCey&?e%QE4N=z0HY7j7A+zp1$5HlLBaK{&Rj7;VJ;Q zV4#<%0*HjDH=|QcO3QC|aTvs?PEasBx&udmaoMi0ngG`b{Vzz?#2D16nL%72Oi%%P zGyM7K>lxKK^$3seZalh;Wcy`CQ3ny$ivP_Ta1nt17({Xg*Pc@p?gweV?!O62WG-1{ zSvu!PCgy;drIch0m8jpR{>}gT%hbFUqZmpr$u!7N%jgQUktx?qc{Aflw`>Z`k#IEQ zQUjfHaFvmOb#&lzy9_&Gq)L}Fk!Tqs@x4DXxI#}u(fxga8SPBwC{i6%g!e^^!^b-{ak7{ z5=0uA6x$6}xrop1;L)x1kys4D-1zHfZ*~q51*V`%q>xnC85$ysy;jF{U0B+&ttu9S z7d*P-HVS~zpi^^i_E1KMI+_892B?cMUW4WT>US|GF&A;cprXl{H3Bc*9#y8!xiC9= zboXWfI9>Jd{9pqDjz%LhQ|Ch@oCKgGzWVm9jMgJX5jG0`^s~uCA<(2~B7rneFiWpOu&rDl5$YgFFiF5V2n1?u|*I7GtCvz1%u< zW5ibFLe&WG+%*^>SDyk@7jD6XS_ldbOT2owZ=z1AR(^JW!`B~mHK~6PN9O5RLxB(~ z5its!1|SGtY{fS_hasX+Ejazj<6C#uASE=3e6;qn5G1C1(wDmE0N}&zlndK}=k4xm z3t$m5Q&j*}RRK^{(PA21RjmL0@1DMzYEi6>1OiKb2rA;l1V(XGF9LxjkkN?wG+K(y zRMnn>XRgrS<^F5`)E52AVkQ2(2^fpyLDBXHxAts``Nwx6xO`9K`kq|*A98c>c|RtX zJ=S^h&-`vuSBGj!Tg%aJf1T)HdM@2BKr(Oc>Bvj=FFHHnZn$P&w;@EXG}>8GCQ;GG zuGlG@kPbD0ouI+MNb%cY?Z5u|$pMOKbcAcNo0_x^%`C=v`n70bZvL41!27Iq@3|NX zp{$LFEy*u6hA^{G7GtNxtqFed*}aifF;ph{>fQdcH^CfAgLqWIoO4YDpeqWkQingB zlmiJ5Jifazk|+!m_jdbWTA_?!-F@RMHUIQI=~e;2CqR0}Y_5ep0LOQONY~m9&1zZM@RR1?5jGaUT`e>cIpP~p*=WR^H z8uC=D`xj$yFlYGeHXhwtFC>yIq2ImO+C2gtf`L#`XJ)-3(h4~IU=9OCU;%e;V#C8D zl9)xjdPh1$w86Lvz!+Cfz?UM?OH!L9r!>6Y-WNXtP2%+Ny&EpA0!9&Ch4MelxZW2< zG*YD^0`cs1xigCat`M%d`18+ijDSKgDiYhDjd^%l6lxd14;=u!zslZOclSY)KuQgI z??ohpP?~N9{NMlM4^!Phu`X^b;s7nHSm)c&$!c}1sSf$&G>Kj}Kn;D^_36%g1LyNx znoeRg(S`KU{~+(HBJ*f$9^bcokM``yLUTV_e&Y0}kY_kQ!o=W5IC+lqwD&V>De3xm zPkKxF<6agM_P!o+ia2apX)Gz)7&ve69Y!Est8vO8Qu{Un*{IIr%ydm9YDpkP6cY5L z6DQ(GnmWHA5QAfEakH36%TK(k5JJfT=DG9ws}xs ztfyznmtFObe8npO_2h1rT>!-HA>Qpz4H7~*DRBR`M=j-#k;fh+E2O^}xq=rP2bC=QAGFRzqo)CGS7(=m6 z^wa#5CjRP+wGTUekFo2SvmTpMbP*hzS6-ThIKzr{MMN|&vD#%ofb?3HA^8B#3SS&3+X5gQ!~q70DNIWBNv86g zFwk@~s&w?H=lg$pW^`j!HM0-`vX;krVSw|Y`3nyTE+UFx9UIovtEMYS9Z6X5ij5Jz zxPRNj0tLnT%dMT)@1O-D5J^>s1wdhi9Ikzy5D+o4C?r<+?EZMH21Y`5r`7%p(B5_| z>aO~zxbi&!6c|kev?v4wuiur^Km!DJ_vXgh2r5Y+OlY5JZH)!6BZ# zd|gH{9f|t8H`l*-0I5(2EHYT2B!zF$CS0tkpoE-jzFfH7{qY;;G5Q`0BZ@pbn7-Na z1on=q|Mr`&kNnL@R}^C@CUj#n1rvTp3aT_S0?U(^op`^rDfz{O{vIl-r}dzQAngTn z)IQJqZ{7tLT|awcS(fh@ipZzml2-ct@3|5zCwg_8K9{zF^oLe2QU!*#UphCoTP>j6 zaA5x5#jo$R8Kek9DFxL*O(i;rz?&#V87=)zU>r}1Srret{y%>6&B0NXQkCtLPaMBv zaQ|5`{rM`8AM&Y7levLi&B^t-#>r5e~1v_oNqn_ zGv{Vm)d7AGA%JwgZ<`a`+88U%hyeHObx5O2At386fGhrauR{~C!BmA5gP7pm-Vw!% z$jIrP8*7g64m60Yx&VFv0YFOKlLLbY=Zio@#gmu&hXHgxma~G@FMf2>8P-P#m8pvN z-nrI9GoRn*bpCsfL|QVXIZfv2q@78+n-gIsv|fia0Y+mq#u%f~wBYFGPF(-})4e}@ zzdeWL#OPFM)-d~Pox`@hw(a7TY>%`4|pFkNFnA9Z2Ku5QskI>8nQ z26_D~wLkni&-yP>lwa^GXLX;w2vl=L&&v=4%*(>^jCU11$e?Tky?eHAJHba3B1&OoV9IbO zhmU-zIac^)Zh?|huS@givB2y7dk@kDoAo;VHwG(wk$Rj2wz&o=@mF7NPMk4TZZz3B ztiFGJM4zZf{JyQdKGb<(qdAFhCrPR1w&fmx{Xb zO~4^eM`K?GQZ*`GZ)5MUR97I<(H`CLV+BHV8qFxGt08{9(SNk)y($Q^dhJ)CNLfW4 z;q4Sp-W?uFL6wQje)0I`?J)`>1~?~18fjK{wX(tBnhF5&0lR>g=Uf-=nyKx@mo6_c!_@R0OrkF$iNV8xm4$bQvm9=e-Y+K__pk@%DJ*|NTFnPTftD0_s#%5WGfO zr8RwDEO~F&n$f*<izxaT9#pZ@#-!~pC!ANRiBy-%~n4F>bogKYQ>226ha2JW}hFwx|oy;xKL zib9i-27qMI(ZtVEPpG!(g@WM3lp@r$JUKB3V>E?&sDnB`3X~vz>ev6D|M>fZ&D)2e z5NfazY9uWHQG^ZaEiMdVh;wMGyWBZE^)qz68K zxaL45Vnwoey^TsyDNIyIz4{5|D<1$jr&U#|iUAd{wKt{u5TXMg-Mv)^#7tmjp}zsF zj_Y}-Y6vmL=$v{7i)KcM`ib8!@%z7Q&FCrKVz{&6zr61~p=|X%xul{{LnVH|g@^Om zwfuX>m*!8Y!Pl0y>$GOwL@xlwkrOE)AO6(@OXP+QkI%+TLPAbQe61`m0JG~P0kE6=@;~(We!m>)yvPfPSc|#%1x{v1^gM&kAdm@9P+)0pc3dz_j?C{1H~iwOB47ij?sYgBLuwbIZ}3 z`e5>IZ)%EJ1$4S|D;1zm`n*&;BNNNNG`!v2H&F;Z!sB}*uc?XO83b}kl2WVV!=+1; z3XQ@Eg=R^%S4kWOyxz5gS)hnoED*nZba!ohnR5}x?2dBEaANR<5QU+L;!=kLJ?LRh zsIjV?cjiX#rt!PC2X9OL{gdZYb=Gv&W>aTqx!PFy_S8Jlr#KG*88eTtIhUncP$f$- z&R?Ogv^Yfh$Fy}yU)6gV_x%UYp}#Ef{rKB^)(3HV`-O%79cXmU56EFN388gca8I!+ z?`ctytRY?eWJX=iv2N_+enV?B372tTdX|L%YM@m+;dJ)$3B5>JBFaZsa3jXUC?{;kRWP7$xA6x-d=_LR;;5z3j=^3#B11fXIfo2{) zWSP_+qgpeOpMg~>P7?KB2*|qc;8ez29|*ag8?asj)P8|+l=PD5=cgns)Q6o5Tsg1# zgRJdDI@b%xa0CK96M&|br8Bpd%qg3h1I9oRsOxxXV}`5`fvvnxzCaK@VB^&;{^^g; z4=@2nUVI1@1QF2?E%|5m?u?w$IpoA2Eo=_vW$MFIz!CRv;oi1{i-5$J8(}k-h;7^YB1SU?s5p;OI zH7&(QqcGmPwKkH*C6Toc;wpT<{t>(i&7x7X=6zBS72my@y*so3=Tz66{p_>x!~xMR z=M+A1zdVQUCP|@)LD}LOM|xW7dSj&R&FuTF=}uVt-FI6vw?3n@uKvv){ObHoF{lZ& ze^7f`>4|aFwgPu+ZkHAv%?`xZ9$6ZMqd?xfym|{?fV? zxMw1Unh4}8j)TAR_rH1mb|$6iY&P|NTm{Q)&H)gqCkTu0&uKz}bGZC|@?b!~iNcQX zvoF>Qsl3x*Qpx1c-_K@AT0}GnNQ-FJ3$!|}>5&TKCZZY%Cg3e(%Q32_(Xcd~##Bh3x+ej2sr01&lP%2&;2-AORgcd$U)%8&BTM z4s9*!1mbgL&nD&B&t)!7TKANHn%-X-IR0vf?+2;VF4@+%6sWJPnKHUA1ZT`ZbH6=! zAOI)))54s|wQThjLy5sQ>m_843=pT-ocLQCaLvreeut||7M#mOF5XsU z8B=I!-9Qi(L1@}^H>VH)R5#k*E5Cnzc*Kn-uMeYcRHZ0pMU3 zIv?l$&yR{FVa!0}C+kOV%L_KV}l27`gu4ea{gPIrinA`n{V?53F3TL`ot zgf}!KwrDjRl!cCk1?ZCkFkq}J0Cg4aqO$duKH{ZQ@d{M< z$!FuS21TT}3dNH*dwUh42%)vBKuRF4j_aMZfGAwD)gw({4!F62&9P$)CT?cBwF^2J zu7j`a)tAL22n50F?LGLBhrlcy}alu13ev2=_{iT+Icb8F)2Hz$P{8O6Wc+N*%;p$U8C1Aq`sJq|0p-94&|DsWbP{$N84 zu2~y3t(79g$_L<@PvB@I;T2}6h!XhA^Q}_GMmScV-QD=&o}Z{OmSzUUhYR8BE#uS~ z&4qjIDL3Hx^nWe;Q%@6}*uhf@2ai{cOKi{627Ndz(!Hc$O z$hwPHwFcrq-pkvi>ibIk8M2v^NvV-a!cHmG*3133zojbkS^q&oH_3ae>Kq=$vJA8= zz=EhXkA#q(^7-*Z^V6SwO8O7?ypWrzie(|mvIbyT9u3Zy25HyQHRn5y0{6GBR)ze)ZMu=Ld zs*x+aD2!%iW~7)QMoWJ;nIX>ma}BzU7ut&FPQweoK7NVo`FDI+E|3`Ey|-#s0QWZW z`JHuEM-Z*t~i>xw;*Z62{YqX`qr3q%ZcWYh3R6tSxZhv+ZQgnUg zhl5`62PVGI((rZ{GZW{$GTxZ@jS(`B2BPLUNVA@=j%yqOI8|?{1n%(UW!Ntf#g7Vg zHv93zwJ~7j%&9u(qJ1Pv*oRmtmeKu>e?0*+E1WU}6{7_ciGKX;>;0*_@!i|Qy$bLn z1D$iQ*dPz)8N$}eS27jOjMCF>;Qc&3bG~vWh~5w+$W}^JGR{LLyWZ3LpA&(>BAFKq z$^z2)7}N{CN)vl@U6sRAmM5Ffc3#uofD4oNIm-#2?N`wCuP->;VUT|ZSSTaJj29%i zrf2{CbYrslktEfBZF2wGF?_I~LneRQ1LNVY0Mbm8T(hWwt0P~KX0TX{cWm=-{^e;L ztyfS{Da&b$QR?tPL8S>!A`_l+wOOG2dno~*IE~TP#v@d-3Gl^(jm?Rwn7HvlB~MBCv| z;P%GYEy4m^`FLOZ*((r7MHwPIc8>7$_0BAM5wCIj<3~4dP2d?-okgg_=~Y4Avkvh7 ziHSkK(V0O1;9Fw!qGIM%j6^Ycdl;YXh1XMk^7i1+9Gx>p)fO%{xy&B^?lBzZbM09p zW2blCd4Y9(0zI$a{DL`9ln%-dAw?2L}y&kn{L|Um`nZd4~72fuHR%3zNm{}&L4oee5d4ft^RbV6>=q06qO;>ZBVJu zu74(*3w_5u2UUs!COY*Szy9{^vmHBPA%#%NB#l8+&}LL>z)4x5Dn&B zba?Xe@UWz)SK7u%e)8EEl{rwwMMIs<`CtR$9NhioxwjgQ976}1SNmD;bwj?e5CpJj z6n7Ne*DtpBxb~M<`@5CUk6E+(sZKLUG!QLGk|RCFg7lDF##)x1i)sscvc|{W^;@m< z`L2isS^q^hQ|7{tUi302x0JV0ez*!in@?Ju7n0GJb5tZwY8B`aexw(gW`xfr0Ij$%Isn6nH6B-g^~H`r)%*IF}yo&z_!k7IDbPxd(a$KQT8)%BSH4p4*` zNfGfDdrY1Id+?0w)fs|^PYfXlQB-B{PQ@xEKD+NWM&dv~_ov~TXGbMK?~SmPjnFku zAm&O>A{_&e2zY#Nt&pfhXdnh9nDzUn?Hr^P6mSn_h^lu^* zC~{uV>bSOt0XmkgeZ1PEk^^WAEzbl+_qM7IX&%(3>14toPT|C!3h6kW14NqPk%uESUt2RgY?5?@pMU2#7SO^5yZr0a#b-uQB_Muu6!3iOu++_OU+?c#LMhDP zMZJiMm>K(uncCy?bQVPo_dpcA)O&|TGMCeV@(KEo=ooQ#nd3a5i8}gV2!nhrj?Edr z4};|#$n=w(AGES@j=#I?{HWXgNg-S_c9?tD&HWZT)37CSqB3XZH5hwYk8iG%+jIL8 z@}>U=OTQ1U@v zBc&B~>=UCivP7OwU6l9z_zk$1jiY}RU4cJl7|=zevY`Jnj|#N!D%cFv#i3>&rq9(J zWd6CJi`Ft{fJHZ(n#}@36bAv6>cp!X$I<4m|NQ*>m!)x?>Z@2%q9K@->Im?Z6@Y=@ z&%y=g^sT_>ur|M-_1XjsAyS0uLX1u@R{Zp{J7Z;42Gh~IgYwNT2w03*9oIa8Xn-Z~ zn9{~AX_FCmZ+RysLPGYB%A?sT0JzxOFE9an?S@IaQ`47}BwU>6JVL?Newa~33l$kV ztdB8Lh@p-GgcNHoB&+oQ6`PI%6E>Fh+MLT1x`F`Ai0@zTPNSN^fj39;ePadLm>ay3YoI4`5YC;WtXwXC43j8-Fsm;fp3kVFw_Yy+soJ);i&N(Mwg)i=5!^0~fGBtevX1657 zJ9evFeg~4yK)vp+>;kSbI*c3A#qUa z?_9E;flC$u08!1vlyD7*ua`GcgcOTt0KJMSp1$7ZXrl~J;KALEiNcO-%>x8QqL5+& zQ?`rw>bOGjI88085q-l=(5li4gisMnEo-q7m6^k{U3+^}!LPwEhW*E%-*pv=W;2c! zy%So%g{nF)D{as7VvkkUtL3-brKgeJ*7S9~?~*Xq1uh9Dk%$0cfI&Yn$ zZ3@;vdCS!{6DzM{=p`%Dx|z6-oEm4bR_|PEBaeo-!eN58180kLlio8!znYAfI7_fp z({pgnXV#B@`mKI{(_Gu2a2*(R4V8rkeY*L;ZuO?>H<|E7s-LwWzO&!X^hp~L+r|L6 z`|QmNnhRzzun8l~Db%VFDn}EyK6SVL%O9VAdx-s+j*GSG@Th>8DYOU-R7;iss;ZDg zeosVNww}hKgsWY0iPkH3-V*=-Y0bIVG~k%*$zZ{}Xn`P$HuD8w2121`P+}yI8UEdm z?u;dXWd&>XV9wwSX)nBN2vjfUaxS3uf-odTvN&m>QZ!5} z?9W0O;Z(ig?yZReUJ)zHQiAnUS&i>2oS*C2Y?5G66f~+d2oq3sfvA9QpTD7ws!)x* z+`qL^0BgP-=xrdF%0VA|I-UYM_VV4wvk?|$zGaYzV}^pQPjX7Y!Yqnv6%2mO{KlWZ ze_hJh;A>40)XP`{cRH~`wD3I>@kGW+$QjIeqEtGbG(?6Kjea;WRP5-t z4SdSX?gtJGR)NID=H{0jS&#V1&cw1{^2li>XKv%rI$+P4^$vzyEB=FgNH=wWf$~)* zhFfogKxVH|2NhaZ$-0JtJnG11SvFr%R?-AfQldm$rK32x`*;8Povg1_^v-#Uu_#7W zjI8lGNz$yzI3#q=uEZy8hGMCU@LY0_i*D-Dbjim}Jm#N(3XBvtCb+#Z@e|I!1~TI2?R!B?;wOg5Ke6Dxw(NDDNflaj|fGmbx46# zZSLyfex;~&m9vEYDWGaF5@Kemi4W$Wc=4*5m86+iC^UTe*<>#HbdPl~~IE8I4TI2s-g0L=%&u@WlUCg7U3L0S)f=z`IVl&=a91BXF|8iCw;-6dY$Td4Cf*(11NSHsC`kO z{6ll@KUNg_Y2f=u?;_x$0QywVYaZV(i~&-G3QG)qb&h+69LJ|iHvE3=`-0_vUAyQz z6AW#y)+hoAMd=j|N-8KyC?txI9RJ~~7vH=NM{XR&k%-aiXoU_GM$n{@&`$Cw-6}*H z7^PV#PNN69DCL0<+TeSd{Y9uk#||FUEN}tF9zXx`wvUx3LGB&Qp1%P+Dic6d)nEp* z#;HLFCA15UR{8&{2>+WHym8vAIb8}PRb*pQtd9#P2C%H+{(dkjJ0Hx%M`$Z!8DosN zgahJo1ptEVmeqxPW>$zwLPbazL5H{72cjc|s#P~P$Atp4^t9N{aL5qMtd6S^01!e| zYMc8{DqSY1%&3lph?k>ykbWO; zuK&|a%o?xMe?|u0sBLyL+wyz90n+)TVxd4vzY~BN%c4%H)+#7m#yq}6fcQfL=w;yb zJ@NE19Q>)9fO+0u;qmu**&V1+Lc{4tW`La;Qw+M;`@^z7=Cv=}*<$+6Mj(HD7(C5f zHK?0$+6dsPh)X30O$jxHs3-vha}_54^js@_p( zuxJnkQiRpzeA|1qI<8W}Q6NIejOABYSD`|TeseuHI+-yL_g7}>b(|ag;$68x9{IZ9b40ynD&2# zD3L@>8a{Ckd}JMmHS%fHcxr}2MuTo?3@Lg*j{f^P=!;x_Yo-=@qU^1$uR^t@BrL_7 z3K^VrFF{gy%g-+3^FhvYYU6*>tp76He-^-fw(~5v^vpYI^N8CX6-r;uUuxQKlSJ=- zQf>UU(y7fjtPS>B$ACrMW?irFAjBP31BPm$xe`|%3Ag{wc?;CDPoWN~P=ip#YHFWA zl6Ozt>uD(NHW)QKCL?o#!Z#Cw)bXn+2Rsm>wr`hx#X1qfY z(R!|eCfGT|;S8V?>h}AVUr-?|65RYlZ_pf&% zy{KoLJ-oNMIfg`xy`#m982?`R!*fRn{Y`P8b91t&|FSJMZ8$_y=PHATBUOMo zwI4JZDY;)*W9-FF6CFeE*m>ltCked|Mf%UeDL)6a{W$3Vbc68YC0alJEjqp;(d$uJ z9xMqLgi_tuOQCNz8hf2)gEp1EdX@IobqD4cT|r-#nS>cm`*z?Z!xE%|>a^G%4+Fo}F<2kFRHDKV4A#{FtLOi=}#9A~9B@ zkvL#;gvWPp6#@gJy612A_DfI^BBPmeDk9Mao6Ctt)vM!@A`VGv7eb(QeKU%Qb7pXm zk>JJ{8)K(}XgDgdcU0%8n`BsK^z_80K{19;+P-+ueNRHC}=TOp<4O#FK>84 zVx*Z<-(E)gEnn}y)toQ=fBb?lMET3CzCenZM3X>?L=;d#iSQI}s-|Kstxk+EBG4V# z7gdL9nyQ+Zf0$!QQHli^NTc7yJ|ZDGDi;dJ9D6Q+i`>Dg$bXSAZihdJAZn% z_xqqw^idE82M`jK`%U$765a?C_~Y7)N+#W^2iOj1WbdQ^-R zw4i>pU)i&_76_v_bu>!jg3^$qz`PH8wS`}$^na=Zr6*_Yx9Z4PAcQlFfQNT(3QfdO zCa>OBsic^;H=tqO=IBLM0l=v zKvuE%H8@ff-r!|QY6(!PYg*-?&;o}Qp1#?x;Ee{W9^b#URsaMZs zXqW78a>my$3Iiks0A^yX(3P<6oK}ihlJltvyA)VNl+lqN{lo8`9AQ$?ryal6{z=PK zQ`)aJ{+p;t+S`^TIWzPxEpfTU91Y5$HnGD%$!LzU&s@tqH(Huc!JcAbb3OxoQbzw% z!q!D7mLFj3eY7j^vRR2Ox?nRfbk2fWPPkM%4z<^*!rUHsFxnHeZ$fGI42J7F);0#P zive;4t*nyPIdFGHxqgdt(h2P$Q4^&&r^V#3M5QEAP09%>Ouk_EZ*PvhR$*r9U%cI^2ny7Biv~;WPfWxF z>vZ6uD+18fs{l0mKlOfEPiLbsnNLL&z`dIju}W1DV`LI3C7#ZDt)jiucErI}jH zv96D)29RlCvN}{EQVXz9s#)d4i4hh6l97lah=xi=zk444@aiyPqVVdxkpfei!ihMl zKw%qx*210xclR5AqX*0jEE33#t=%gXq}e-K<0mOE`OnB|00v%A$jUF z^YlVW2MMw#_P>BiIukJ!`e^Wl4;EG66<w)ZPjZ-f)Oy)h{i+EA?kuRu`0q|K^GUWnl()nQs4$4*CUO&pq` zpcJJ{3U86YEC6nTub;hy8;d%x@xkr&^#YFYDydN~8USs&K{7S#iyIQBMwSSm__H*ELyB$3}g&OKe=&4{D1%Pce}QZVguqV#_`BEehO_7uJifIc+y$Q zKg&>dkiF9(=^7UEbY@BXB5z;mM))AqZ=J%LNDn~nywS>SY38#rD6nLm40*b-4b@Bm zho+xbk{p4Fw>KqBZM3weLusDbKCiKk}!M)1d#e z;hA9(APHGBGC(;CB~&61D1mNj#Xj)2|MI7x8xi9Q4o#6;}&J7&T8b6#Hq+T=3{o(NXN9!n% zo)dLf4V#YvvfkXB{olT(H?!)*^PFiwf6V7GfDC%>wK4z7H@*5InzM$|98o~M6N{Bu zq&V|yd!hJ;-+p%>zQU*sGzvtcC5B;M`DqD14R2`?MqkK#JQ2|Mr4de5WVnzBK{9@J zM{#Rh&k_X)5o-=#KD?u>oQi4j{hRHh2zWpwhL8c3rsrw}HLl0%MM;wsSv#T<_ipOg z8xc)(@2EN|(M{*PSkM~vKs?^&0xzVSr6V={Tz|&XdQIH5XF1%y-(Zd^SiUM!|U?vP~ zhj~)O*L-XF$Rpx38`d%6KXd&6)@}LC_Cq#3h=O3DA}cr?{w%>@79XH4WMDqMYsq4nqSc#fa84D z|1^r9r+GvdtJL|P*!O&B=EI%mKTO0E{rDB&xLSZCL+x!lswN?gghGrYMTi>{+?aSWm>|G!?*Rfc zI8_?!Vb$%Dayhflmy80G%s3kVv{vx3#%`&DiP+wqQ45Hu@Xn1jCv}f78>Ixv+Anwk zS{ke4(*@uugUqDfV>PW+5G;zKG(3N^Q${CRh{XrDZ{FSjDlkw$gF$JC%@h=YkjmC~ z_TomxLcEggXL3sPfBGy}^L?{cyQA)EGtdBwF}T7i?> zr_t*2b(-|n0N2a>aW7v50Lilm4Q+AIN`_g9?Y#qtPd=#k?{3!eF0~&L6#`nLqcFej zua2uT8Jn)GI`peG??Hh$Hw$?ArkVv%i)c{VPd^`P0Fr_QU^J5`j+ELHt4ovDjL#1@ zj_3C_Ipr@dO!hBZttdu`OduYnh+XyAOaZsHKCT_h#CMM&8+k{Uhc(T|Nfi9ql)lmBI2VlMl)lR zz+meq0BN0)E+!d&Oda3@4hMF9h9*fw%_v5i3RE<`zyV&MhA$r#BkuxHLHDNR&JiLR zqGk6QwP9f^3A#G2SP6hKh(Xa4vZ#MxxO;m|l?Ef+?%q+TpaNK`{dMxPa*6vwE~817 zG}22wZ@uIVCOE3FKMMv`np$=12E=NwlR;fl+K6Hmh+oy&$X1F=sBJu+%)iXx*_)li zk|Kp#{QTbLW&s9xC+bvH(mOQXP(@wGJ3gT?}TF zAfu;SM{laNKfE|N#c znX8pczo{`9FxGM?^%wowJjmU?FA7|CkC(+cmu-RUD%AbAXDx-YNl8lM2n_(#bfzuI zdYV21j!)xVU+-SBoIoj8$8Lp-zB81IT|DC0cz!Jn>xa%2Sq#8>X3fbcHKt#6A8PBK zp>-%e=;$4)6pFM=y=LaJ6_(9P98G-Dex$OjRGq4TQANj-_0oLlHvjh5-+jL?fd#ED zU_^t8Px2b+Pg`~ydF*Q1cl=$<+c|b&yfCWI()r%ql)g#?vz~0#7!5Qvtb07XwdNQ_ zlz#m6_uDd-$_8tF;+n3GtBC)59$w8pl<9r9`=eoV4P!M&GwQ)K<_@JAICxaPs9m zF5NKIx>-{Uh)K3U03b$l&dm&O_N(9u6BMW(-db~n6RJ^36__A`Y``CSH3VHv%PSWE zR0wbo7Od34IZ?()U}o4l#7>Fe#tN)S{HxDK6TmxA7-^9r;*_dUT=KY-1|HQI={z-n zW!-GY)~Oe$-Opbq{kPRPk|o<@TZ}i*+FTh4vs9*xWB|(82>ejKdir8d*1y{G&#U$5 z$01bCdl7*IVBU^N$t>g$=CD6X2e2*-F0VI-+ z3zUi0xA{>bT~>fxo=EywN>l1>(U~Z^bqmOn*EHtioc9P$YVPyrnL-6-$FkzU;7DDV z&Do4t3Y*8DUo*m@hNzK{+7FH<2qG0E0uVT%2OObM5rt`iZG(k2SCx6dp}i$05<#Oi zC)@Yalg>V40G8jq6W>Is zE;S_80wO8Zul?5cPSUCo)M^TD&81*H?*&P2DNG+h#k!`1#z}wj4?X@ayQHrF-^?67Sz$$W#t2K^2R{v z@{iI@PJm8*G(^Gn{xq0SpseoQ+|X>{M!);MpKe(lSMU&stG#Kw_ZAFh&bbJnz|&U; zK?+eH%cIBlZj1nrnDp#3=it1Pq~~PxVHXe%-9Jvd8L*T1Jk^Y8swC3<5vJZlb!A`_ zzk67{+L<27+Ba{HW_~lmv8k}tzz?Ebejj~mLC+1U!5p<9Z*;uw0EOI=MpD*3@2!T#oq|5Kp{+>Fz`|rAzs*( zlRh@(;L2A3u&Bl=fT%>E4A|X2O!^`YynSoM?799S09q+eNKLR)>>T3FyFH;g+DOA^ z4@b^j&hdP<4eWSXy+w@yDlMI(?v+47^%XIr|L*n9k&Rxw-QPQyrkA&Ugbi@%cI?cz zYnXx4T->aKAdA&G=QT`veURuhf10SH>p2+w=~gdAdMt<`GS;3S@%eCMA4-3K5HW9f zSfC=m5Dn*v=YDuLUwcr>C={dMMM89C&=^F?HI3^o7D|Cs)ET5w+9=96=qP9r)sfoT zm{P?Y;0YhaMG*=G^v1&IFp$PSXVkRp>0Bzd)x76)_E2P5JkxIl?s!3xJKv#4;uq!$ zwiFpOyZ71#1WV1ZF(=8@ICY@yT+U1kSo;t+F&@AW!a*7T+wZ^Lm66I=f<>!LBB(Sq zaaOk~oDI956~P~GpgtdWeJtMqfjAL-@p#R%EFdC&YxiL15T<}8?H>?`?KoM*0M{9u z;O=b|3#v-U-r-RhQWBbr;^50@41Fm96I=FF5^W*KXlB6PA%cmjQ(%2kjQz@L``QQL zR8=D*;ngWL0#Weo%Yz^V35nJHTaz1WY5D!wVfI4YHc#w%pAGN$G?=7}uC!i*5Q0Q7 zlVbd0`|#y%b;!xr&$i$Sh&H+ZJj>W}S+dT>=D%~uKI;Ys2I`!xn`sAnvYy4xF(4za z=e@!U(0{HkB**8)F9v{~HC-bo$rSegO72>)_)RZ)SI0OXcLPQMrAkflm#7j&Ow>dp zB!V@8ClQJaFg2>B0;**OPm+GI)<|=`vZ80 zTs5=F>pmJeh%-7xpne;@^!^0&^6yD&hqDZgM?wzE&w1ucI4l@*YV)?#KWBWX_Qwx(~#axy1NP^Vbm2o zx!wT)G4Z&uHU^`SRm9!_qJhw^kXSbk$>kKGzkC5e;{w1=!k+?|h$&vZJAf-lDDCde z4KJ%1cFiYPTJsGCPze<^V86tR?R^##MvlZjySF()(?|E|0~TJ$YmN^Ca`Hc^#-=5} zk`gEaaAflJtKB1aJn+7KDcZxN8eBId$Dfmao-VL#&zjo!Q)#F($~@%oXmsx<2fpexufYxla}W`O%Rkp zK^9;EIulebl)fsRRVoqA=!_aDrN~rerc#Oq_(&g2D`F-#l^B#!Q)pQ~)Jjt2&EEOE&WJxVIi|4Y^2{mq*eo6KhNR z_Sl1%0Rv)^4(HvlE+;CY^y{z7@jw3IdF94cgsN(C1STTYAPoy**rLA-vY#@Sar$+V z*&2cmNDx~59FsBdj~?CDSTZu|=*7GJ{TWQ4;h1vK3e_-z{zHW=#te>kMHSu~~HUZ33ER85Vu^0C`Ja`?3{*?$_<>eGB@d6K%31gabCOh6;0n7nv* zuv596Fn;pph{Y!1bJb2{8U4Z61No7<$FXMr=P&M82D+%A?eT3>`%PTYq&bmGA_5Cj zyj`2Yz-Vc%g;Xyh5x5vQ%2EWm9KLfIWW<8*hz00>STkT}GTM>#m!R#`HziP*iY43@ z0%H&aXhQ$V+|?ivs833SMrv$>$;1y~l9iIoA(cy_m6Iy~C**5;X5VM#^q|9XeWhcUohoG`cuHPiUd&IY>^S>b_g?q74#fA;>g?|jgR-DCqx z;6|hUX{n=|J7w`Vzxwl@u2oSbCf_fDCbU}W1~76Nz+G-${=T8YJjLka5`hS#V;n0U z-@P?$oG*ghzOu~1nr7C)?m=(#N? z&$30G<*(FojZKEQ)~yTBt-aay5w>Ug{nlZLQE3qMGA~;?I+EX;V-((j>C0nMB`GlRfHE9zHIUI%~kFWsiupl zz^RHrRg>sHI*srKF(fLss){;Qr`pPZW;UfsXR!N@W*UX_azL3NNw?b)aGH8f-Yr4mJqQAA9OcVX>sfAigT<%5jMz`}d6 zO02XHR9%{UW;R&C7OrOKXYS`KnVdTYE9CLZ?EG~;5a26k)50qw3&q&ui-)&8qKHHt zefwg&6wnDxoN9=Ku0(*YJ3=v2;{L4-k4Q37Uv2GC5v?1vQcJ{M#<6he0)PSE7mf3g z3HFXEs3++d?%e_-WR?EE{s&1|jNa>v*gnER#Yi{t&c)f|dt)V3Uun01R{^TTzHt*g+ zpE^A+ZNeGsQjD!1uy&xZ@2%Tnphh4FT}BfNNRmlHfxR}zX?)N)zbFS7sqb6{^i9n2zJ_B)q#}+jrf;!}0Xd`0&B_ z;PKk@;pFh1+qt9fZsXkz?5^>!uyR~DC(^imA%YR0str-sc})*4DC@s49+P8Yk~95P zIh9;#8rR*(WU8leZi>l#r$;ssC>`Cuc>(J>Kni!k+EH6D*d;mNa*)O9(5zO_5GrfW zvbIM*#nR)VuozUJetck~Z{AFQ_hf74H>H?Nr!&Mj_8vftVIfR^pTztzVoH~1nI4Zk zX{xTOFjCyRC1W*&O5FIM}IZpUMv3$c?*3qbgmiN=4zWgG?Ovx zY?w}jWf!>Mieg^RaapqYYyrk1C|nb4GB=_|PNMg!WFbpPO3WJ>Vo1ea{+IY|o$y!A`sj~u8a|O-CUf?8 zwd-)7+}}>FoGFykgld9Z(_~)@|2MU%M#hJE*KgYi&?o|Nfsj?!T`WD{?#x6l`3GAZ zB^z8(6zgvf;{W{9%Xd*r_~e8eDua@W7~|qv-FervtC8^qEk;pI%{DhCcyMDJ?3lLS*S|E zyFE|~LR5HbeWV7bUD{=JT-OL2Fp}zI7I9>Fy>n#Zy>lANFCW}SfRmJjhMB4RAmjNQ zyVQxoKJ#oJoVr6Uw;49V`+`aOGnz^9;@#1k1AB8|ulK9K0^*#wVkgm@E_mni3 z@;y$Kcs>_4?GuW4j3DVb-ax;bc8>Pbrj=!Jj-*EajWEA4(NCO4@gZ#5;p5`y=Nq%X z+AMFyorz6D6<+Pk{`h?NSKn;?%h%ig`t{B~fA#8Lzux-&)9GJch8J6L|6n$XyLV*k zukXiS+^Qars|_o)EG5E=X!3wylbUHC+}ZE*IdnOf#0G<=iuydh$Kwp|v;D=n>*s%{ zX^7y7F${Wy{$UKIa~61LowKEIsPpQ4i~)obdf@gt){_jK^LwQ| z-$JaNqUl!2$!njUOcT&>|JEjB0BGvo?e0SWaczR8mNDci065L8W~*reh!{{t92}J) zBF(y6H^zm+tQNjo4a94Fq*Ap3QSkKDw#k?TWBJ*G+XXQ4U^H#=(#G>(k@wj}&f#ct z)_rSP>u~M*1ud)K9AYR%-PZ2m_Mshw(ernQm5iVTp+a#gw3xx}4lT4q{JtZYWT8T* z+~4CWWWhsQQs`kd=3+I}o}noQ>Y|t@@UZjE?F+6Smkj~W z)1r|ei@gXk^k0tzBS5iW1z5zR8#w&&9rvT#vMC2H?Cov8dj8`1H&35EeZBSWu$rPM zF`luY+bC_kJCj$t)%S1qzJK-R#mnbgub<-R%}qV{{02Y2DYr*)WJeNa3L}jILt_Pd z%-dh4&~`fh&uKd@KGyPo`~Yk1f#+Y=3idCI0dn?T=F&yX)fwtBGYCj~!Fes6t;jD< zQE8n$bV&%Uol+1RZBRg5)KR6ACvOk_{A{Ox;@ox4W0uX+Pw)$t zB!WazL<{d}R23C?;PVG}6fs1jUwgT|ABbR3)n>_E9oINR2_r^|JDY_Q0tm$ZvkwN3r)tFZF7~D|iZ_ds z!ri-Fz>p|E+60Rbch#LCmg#3N{0<BR!li%5BJZ*=sN+u?q(KaM*`hr8cAfAQVc_N$}mA!yF2idYn@ zJWOMBq*F>Em6zRUuMWercl%FXJm21WK8kN2tsj1VV|J%F9NSc?O4zc1>IQ&T?3{Bb zUx3!Cc!2)ToxVLj@H_Dlzb_4J(REH3035~u{RGGS)Zn~vSgv?G_bG;s-OXjuI*6s{ zTzj1W(kJq@GfPB7Ng;hPF}UblrNys)|8#$*m7t8JpimS5%t9ItNegIRuIC&k00WJn z{H|VxhJk2^M(0492xxG`;|Jq$;Yfpy_NQ@s9~21qp=GI8$0v^fQUTTh>tjsDg^^U9 zg?KoHvG#&!s?IHYx(2?@qjq!Q`2ULMj7%b@l4peJCYAPT**(&Uxec!U$ zKwJP7s?_&|MytiQAq@wEM4VMq3d<@uKQhXVJ9qu&gJ)Y)`VI9H0i34&+*mu4x7^=O zsk71Vuak({xS;l}05G7R=p6cQWg*)JVnI)de8Br(iqf*&{}3%@H+t~j!CC)wF+&7P zg&&C%&)f`4-=p!AgAugkHSFBc6X`_QXu|_ywJB4w>xvR~@2-VMo4hry9H(3F-adW# zW@~>|xx%~?2QR@{V*gLqFr8pJ#!+EMo>M24I+%KMlig`}y|exH-D`&ZJ8SX5y4~{A z5f34Ok~)rs1_L3jC#--9>cWrH*i|0~@lU*eL-dc69@8nF<_YI{3k8D@FMUOz-s=~5 zgtgPKW5G4~d$gVR+A|A7?iben#lFxgx>t5FK--5vf>LM|OQ-NEVkAY}q>}YNzc~1Q zhli#*9vdZus47rK1VaQws7i%3k!hVbXeEf!%75CGaFFI$a()UQ0PlLq6+ozWuG#OjIVIf(O0I(2ZjLLAaJBN2iKGHV6u^zOu_6|QkH-B& zR+h9fnu*8DC|aYJ?1rLZrG<*Y17&4Yn8)jP`!qoeh1jjlb+2GFuh7mvWp!MQDPw9B zQ$kIYLcoM3h7|K<5GQF&h8Rs{Kj6jobQVAhBTj$vaAQPxNwW8#7{wIQwFF#5CjLZj z>)cNl>{6u7=Wge7UFi!0qcSR(Y&xkz6)=53N=m0DGJjf9V#HKDjYubxfa zEt53{6i{W12o+1`RGP$mi!G&%2QtrPZ9oQ^?%sMQ&p=57>(Ou&v$FCYEqU*7va|F_S6`S`|P-P?FD!8*$V79vVN+2#1f ze)#i~7qf%iJCpGFO}jNZjB%ez2h}J7C26q~wWbo_NoOP*4Gi>baT7G`oyb0WoAhlP zUI%Rc;jNaX@DLf5^xP-gUsnZSYr!F{VyPlXg@TbmEPBSVc*aSphr zz%SGmfM!7zAVggt#fez-5kI+qqXb7 zRnB=a6RU3DsB6zEkOm5^jw=!|r?W@Z&oIz}qZmyp;`KI4(_%Dou$yCdV+>`z53tsa z<)@xo$K?BYoO#pWby7niP+(n;U2}_Csc|HDG#+`g;Jlwz#?j3`J)J#&cNnCAIDx28 zV69hIs|=_;)saqmromL5dC;6jVW%%%i~1uc`LNxwo2(WZd!z0iV91Og0^iOIWbTRT zsR1ZbU2wiPH@&e{_4a zi9Gx6x$#$@KltUBkG{Bf^XAx1G>%ZMX)J7Zcg=r(XY=2E z`QYFG`0>Nd^>y!@cvq~I(I1xf`qe9xN4H1u(G6Yqm4qWiQ}JmXhG?ow3BXpGIlWC; zx(2i`lnWxCQ{LZGz69r2`&nLpSgiST!Xp>24^h+bDQ8YFgTO0Bf%F znQ3ih-N0GP&74Pv_O(Il_HLSjDoP#2@y$Oy-}&ZMImHC3WN=PH490pCq)(Pe(W!jj zPHw52#`_6So&eJqe&;NY{ezWy+Z8T(wSfvexP@D5eq3lYJE-D|Ew}=NgUo+kF4YEF zq5rFPIHyRNFW|;HBvvXA(P?Q%B_aX9snSeU?eZzZUakN@ASy2P4w6s@GfXQS%_@qh zlX2lTCbjoa@>W<4!7FsMNwvB4v@x+yTNmr(E+G*v5U;m(LyXGk5g*>aQ~0!^N<&(? zq-wA=D`{!PVE+CjoNodHBg9B!jieM%idc=h#XuMkL;_=+mf$}0?|${QNg+y6gEP|r zF=ygUrAhBg&$72qDel>__APsRg5|@+=YThz3z95sD(F6Prt7~N`QkAG>8Lj!ox=s-W9G@eEsZ|MW-@y&?3YK>3BM|7vH~dv)SDO z4@Plfhh~wgpuuS3D++1CC^dzx7QB0ZbEx-!E*JY~21aAUe&2=P(cUcRMs?GzI~eW# zwiz~BSgE|wr~CH;1JSuofPuI%!w=WQHCN&B6f%-w%QM#t4|fl9vn!6+f|(6o!7Eth5ED;K zk;zyp;AeeW0|2AAY%kQ?{=qkCy58Wouez4i*%Wpuo#D}gbF^!9(-6LWavzO1_z!#H>1Rq zROQ~uJoiskgPG&b2jI3%8SdzDPW&+jzYID6X%e0-@%(Bn8jzZr>Fo!B*8LXalG^P6 zZ!)+){N(=f)uFwLJrafkUZPhzv>`w8@e=}rCx}+5TKwH_FaPzcs|fAE?+HwRsY5%N z?^1pQg(`^e38uZMM3zb>#RL*^?$I3mwZjNbyFjylubPT z@#3N33%uZ(kfkHdct5r+7iHnz8ls73Ah@Lp@j;01p2%GAw zXv|WEcO{pUHDSCXu<-Q*8D1i+_nVG~n-L6lon*$q?PLt^N{^?Labg=>`U7k?2e_HB zPrb%4K{O3KoXP8ht3osT&S^jbThg?y)?WSY>-EQ9;i{#C${Ye=3NST?!4i|vdh|aB zd8*v{B1o6A(YtT(UmN8gqm%af_AgFjm3+?w@uPQV5*u|h$J5Kzx6jacma*7U`|?e} zU*0tUZs%b!!I6t0mEzv%f`owye0JHfalEf+^Ns|3{qk%O()u1b6_1}iH&G*FY#!b} z73;N|D)IK=X}t*0>AKnf(_bM0;050D zV)f36GKNz;OZ5N)N`$@KZ4Rot(rUM@A|_?kpne*w|Mbs)ScUtMHAts95^_M+RVjoN zx^GB^V+TFado{ok#qCoT?hOml!@G2Dv&3z7Ak=)@P?hO+9p`>{Xz ze>JPhjq;WQqk~zq`F7o0z`Y8v_WoS{?N8sm zH}{&cQkP1pDqsNBC5RfqDrluzRg@|wpH(so=`^%I`{=EAAI;sCt);8hzIk?awOqY5 zmvWVUFYsYz&_RNTKZ% zS9bY)4^Uv~3GN3e48vdAbTzsmE1?2hD&V00#st0YbJ@PGOH z-!^)7)sUhVOFKYYunIlg{wq-l@gtps{*(xf-Gp5xU)}039{Q zGO05cPT;`vt5t%Cs-^b+DJz12ne~*QyW>_x|I@9wD2EKs3)$CC6I)(FUC2Js^6cpWlR9FjPf}?A4;;4cLs+<+ZU*IJ#Dfj5 zv5Ef|h7X^XSC5GQjt%1XsXpG+QTT9R`Vyfkx5|Q@Z^ zoA186O75X@!THJT&wuisaP77fCc+$+EV`ShCY3eCs#z3`ZZSm%D1apMmH2ul>FtLn zC-YF%m4~nf|Mx5{Nr_evGUe^z?dX|4JdI6#tF}ARC&YeU^^C4LIQN zGQT}@+(C01;H0FOs@cg3^Iv`T^mpI1O}N)Ys7Q}WbkCuGKXf@*#?iA}5vMipYdfOb zqM|ReT)&!(V(jpY>rsAAfeQGu5AJD-s@gigeEj^Xg-z!3pjon0@#l^JeEW`|>h5MP z0&(0sQCK2rl*?7yBxLtQ5qX1ss#}`?5TsM`SqYrDTw$F&IYFP-;baCa7puGD_NL8~ zm5~KQv}=+&XuwyGv5G>iQ%a|`ynP?)prt?6y1BD*d7~}r@Z0+Y*s~3?xeazB^iC=V zp3A`QfYjARsN6oHTeF^Cg+X0cEV?A*HqEL1@;Vfs$d2qiaQ zqzI3Y;)z$+@csJiqK|&OH1@)Va0&{LG`5s^wpjuDYX2`;zkiv+|Et2Cvx6LroM?w6 z7jrQ)xFs`1d{nm|oO7Y+`SYjiHc2%z2R!}cqjyzWNaGq^lGAFHSrw{TXvF_;@$?@) z{p6qi@abodpCN?VJcPEgFLhXx(|I^?8=5dlW zg41b9+=k%43;D;`*hvl`#vT{+BK_5u59nV}__bNW>bEOX$Ac5h74QTNtHg`-hLl0q z(A^e;-A*{~j$2awcM-Z90R>KK%>1BjAZLE~{PiMhFODYrBfLAz~ zU;bT3($x0)`&vh2$o1XH7U2n&n2|pOAV$4Pi1q2ed}^OOG1FNxtLs_Ns@GB#X6l`o zVh8-il$;&vVjQ%GyME8kA4>yPb9lAQx17r`7 zX5&vxbGVd-@cjF?pr> z@a*g)@U&J-O{kdC9BGUp26H{Y!P`IFXWXRFo#BO-?04+Po0q!+ewx|81%f5ul3fo-d~cemKoF%!K1 z)&p1s5TU>S>@pF#`r^YTw?nV)j>>pDgBieLQW20TL4bL{*n!5{J@$J)#*g>DugY%Qrpi+{E7XAB5tiC%AME`xo-+lU|nTu#283+Uh zT(0otw@)?9;U*q`{6WV5Y-_)EuA?tGyAFpG`)XGc+NtlT?UdT&9hAj^`^~ zN@p2HVen+;W~przE-<$Ux`W9XSv7icDfplN%dcbgzzh`$b4x~pxR3#qY$E`69L-+5 zKkNEqQk(B=m9rjE>3HQ6e&GA z)9-s!NqBd*y#Etk%+q(*yKY7r=G46l7+p1bCJcrG{y3z4$a_)d0G;RZWOee)r5TDT z-9|nY2pXGaeW7JnTW^vB24j!n9?3^zfL*hp4nwLof_Jr!5#jlv~{-ard(cP#| zE|$wRAh4eHmQo6;d3~QG72uy<$olTMc{V5zI=z>;634yMIgvm{=i{fB5I`i0mHQnB z;M(~mqA5BB1|;IDNf|;|2~Ot-k~c;p@7OZ8HEGU%C}r87fU3qAMa0YvNRC82y);N2 zlU0JVxh`sWa+Pd)x=U5y9yapI>pdF%P9OlM~_|^wSY)Wh-tDVJ>I=$2u*xQ1Nz{j$O`)R`bPBM z2R9hwqyjB1J#d_p$P6160lVtQel7TW=Z`mqj=b7@V59b*)gm`WaCh@4S<$IW8{G*H zN?SjFShw%Y(}}i7>&w;3wE|T|PG?m>ti%*15JJz+?zgQAtGb$#fvCtCQP=f(o5T=A zYlFBmC8*RYm4fP4(`NMZc^xRERqFDkhi(k6G zjynCD>;@d4NbEVKk^Le=&J315*3)i4!3sd(6jsWNx(~(vfe$dz_#YRViKB4ADe+R9 zawtr&S0TvK=Gll)yzAPTB^5EqT4s+M`J11An`Y;xRg+?ts;ae0t-&A=>214)Z|TI4 z0360l-jdj_*w6Y9UV&M_{gau%3{2GTo?CQy_H_h82yGj=d!xFG{%_x*B4*Y%+gyOt znFJ;h$#K=Bb?Vya_Prd~-@6WgQq0WZ5ojD2t2Xz$=gNEYQU0YPRoo50+m|$FA%syr zA@4xh%rYxxIng>=;H&SRxde9)9^ZX%KFewoJ>-sC75`T_9**4Uk1t^!Ti5L1@cCDL zgkWYV!c7Q+N09aDS5Mc!{_N>5K3o0uFaEHCGE`u26HgX|nhSd*g$mIO{g*Bv=uA>s z@Nu&r@ZfVZ`UH(Z_864Vha%>N`gmb4ie1q^H&P4qD1Yv#u8mNNA^Oj)iBgU68ikfa ze9#w?4z07^i?<|dsZLWhkD>C}4Aq=6Q`U;0CN-p@J-l}s1f;lx zXO~NdErP#QH9tD1rmMgd;F;!@l_M?Zn8(eFeJR;CTpkBiVqYRmzi~=%lUI5fi^~Y< z8JRE}V`DJGF32&AeUYh>(FFyJBv9;l(F{gysMo*@oLOdBJrd=KIbEx~g@bdVY*5R8+|J`LBJSe?y zUCsjDeR!tcl4g)^o?Nt!nB}pMJ^Kbex4!|i!n@=37Bnelo}1}8EV*}zTJn_P)jD0R zUztM54?h7Iu#6dhaIwNVk`!^9DNbf!XQb&I3{&rpTh;*RkT3n}*#&B57D8xaq(}yq zt|FdXtPCWqM*G2A3PXm6WoFGr+5gQCg&U#&kNuq96xp+{YEJ`oOCmJ5K_aWQSHJuG zyFY%j`rBW9@!8XeFarrHsb-cb!a(LxdzVsV2id;qUj_<;gh+{D7+}F=0TP>4{O%h_ zZ!*@szK|xIDu?=@b}~%%EfXb-;r^y9&s0ISud%*{lKgA=go7{Op0SZRWR^VT4TP4| zqsGgJbAR^~r(6mWRh#4~_nXOkr;8v-7=a`t7?RAaK5N725{tFWm!=Xb0cl=W^R?-f zS4){)p;~(#Q9%L;r3ZQ?>HKs?64Z6_WgBBkm3Y-Y`{+S+ze*u3Ys3I2;lfM^eI1MQ zt_)$LL39j0=M{8v4ui_Jp4syWgLcpaNjAzU%cJ`rTHohLIhY~e*fI&jm%9d11v?AuJzMl*bMG)8_n#@ z{Uuk+@5x5yot(Axy_D?vg-Y?(xz5C3DFII}R#y#-NDh!GnX2{^-Uk;<{}XLtcgHJA z2;pv-@P1n3q!NKSxr(@IzSpfC`5s=&uDUOH{LD=%IK};J7HTP3Qhq0ydyBJx!AdcQ zrvqA`cLAW{@l!O>0jj5ab2*;@)(hiPxV>8PPrQI0>GX6+1DkGc?$%d(yRQr&gy2MD zDT+kafBgFSHAR2osvnr4tktZe0F?u-K^$cII4rRDie< zkvldG0eosuT8NWOZF(n4=Tpg`{(Po(V|x`ieItL^k#QdVkeloDzhu&Qcx*TTQ3uVE zyFmy#J%zMBT@~cn2{qpHm6uOv*skB5hpNHf~ z2ncnXysGD^UC$*gu=?Php7@o<6;hi|+1yiZ<4#TMUPH_`j+P^$f0PbPukcZ-R2|bL zn6xaqPrxqE?8z9G9eolXyfrofd%KcqL7@UV0?)ntQ485nVb4ntILFvs+RJIH z?A=HAm{0}8lqkV^tI4uz;`r02+8eTl$5?}Hy{5}-dk9ch1~#*pl97qJM)=~ptF@ed z`grwptrpJAC8(nBGH1_N7bi?Tbvt^d_7!UPrMw^}bwSEB(&j|Gzmy@LL@FB<{f!by z8S(6US0NcLN*1e#}-XeJPU_4L_A^p^7!P8wM(lQ2{iad@cfW#rncsEeiPcbCl< zkDr>Vsfa2Z0#iES6jHR)X(Ix?M*H>&-Z^E32uoe-$&*>M>4ZFx1Nci-gl#i#CVm%N>i{Us5>7e#H;88|K@q0ZqNgz= z;~I-gKl$5Vd~z8>i^@QUnYSW{mb`NTfcMrV`2z4jy8F_~o?8k5uRBl3WC@3lBq};; z;;nn9K>*SueEr?C)OQM0)hrivNMJ4I(xaqRtW&b&M*9{L&7n12NQa`;IZZ>g8 z0zUql#)Qk&I(4Ksdw501%upP_GxSoR-45z-LmQJhoZ33SetP+IW#29F@#jxk)X`LB zCZZG?ESXW&ln_@~$g+H-KO7+8#B^gqcKrX@^n1v*}44#!-& z@iWKX#cRaQ9vKL}>d=sTl14;>JLpl_RZj(eI+J%#c`hqK>XaIlmIHJqVH6n2q#=b_ z40Y15YFmJn%si@~1}lder8Rd3txl+Oh*otiRRs;Em9a9+;dP(}Sdc`t1{2j3+qw$T zy$U?R)d%;&Nk!%0Se)o78_8xU$o5dbQIz)b;TreX=3hV~=-T4&_8{4isonUTqU1m1 z$sDYFQAz-9rRdUoarBh&!S1)1I1>}^&X;4m#h0!36jY};DCRU1HL4Kwj@+RTGBjSL z`rdCodGeb-K8*;idz%_nI4xQ9t{KH1^yz#()$JGd5`Iz6z~;&`_#hgQdmdtV=fOM( zvb@v9a{ct8|MK3M-t^5_-!pdZj@xv&Th@BYe!%xmYsoz?$g|63>L=!%55N%>5=C@3 zgk*>@n+%#Dot>a>p*R3f-V2%B&6(RXPvB1Lx?x^e$P(5IP2^Ge^uk(%2ngDFu*Dpz zu!Kt0qscBk<=3sTyUjH{dIa)Xd{PhjaPM$Vq)X4HHPTd-T1AAlSAY2W$@3JxdLGx9 zGt84Yz-HZwQdP{7NFOC&qy9JY2ulx9bV8pfg*!V8K#x40DxgzFVj{QW`;Gl_L@)FO z$^9~m*VhN8=D@G^fdc@NLPlxIOP-$5;qcHi$HT#19Ih99gpG`tYzYmcM~mjx2f|6L zS;+c9)jX(UZ7YS7B0#0urEnTuQzBg}hvp)Qiv9VIKRi*)y`6hgc{3BQC@`;PA$g;0 zYqX)cs?zGT!h3Ju7aF_ZO(B&-dzUtdiV8b;heu6U_pA7zYEQWi76slpI6K^tGD4zr zr6{TUoh<=953*f4`Mx!ab~!^I9klt42m{xmiS`en!yN$MVCa_bDrw-^R2Wl0UC9vO z#6Nf(nFBv+bobl5nm$DXdbvatug!`;d7*Vx>EzeQx|)HqN_=ATzyIx*S5Y8yqZ-l1 z7*v(eL0~9j5M4&V{1`aD%#wZVYW(Wbb@!g5)!oT{Vd(ANvKkD|PcUN&NC}7xPo6b- zt4lG4P!=w;#Q7bZ`L-TLm^&bW3bWjhp4X7~aO`2Dj*x0b<(Bu-_xBLLsVs=v{kE-q>!P5?$GGPw<Y_yRq)eQM&q=jejazBXU{yF1KmcqK_UbXCz}{%3YvSL!FM zv*Ut$HUOY_pex|cmS}`ArD!H5r+@tV^6RBPN!901(kji(T*XBgLhzK3Vigo->Mrh1 zT4%9Kijy{^0a@yMF#pLwtgbXD6K-WTBJRXPeDEzAWJ!GBZpc)%QvgBtR|c2gX?<=q z1=|RQM(f|nu0GKP8s6f)>(Gs2xb2v}e^~$6d{=fGDE{au-`!bJxE}}t9iA8y6F|w8 zRR=P@pE*!cM^<5^bGOy@Bml0Ok{U}bP<=_cyfXDGpPu6B}b|o&d?ivY1rd2yoqpw z6Q0==?zZRF-)#KsNg#L71NONJYyj}O7*cvJPM0vcbm{=U?GaW^Vd>31q@ScHrs@V0 zpmY}=)`Tp-;rR!UjsVl1eWie#Igu<@j8s0BlV3iLzyEx-T8E^It7xH`SxjgnV+1WB zwrCSfTofT!I7*SAZ8wZQ{dMeV0hM+cJruNaNPn?zz|rvuj4-T3ME!-#3w>DcnpVZ`%}RAel1 zduMBvQw4X`5M92zY`%QDO4YqTeD(b4GPzbHa~(Ac{d7gQj?CAE^APDhy?jHav#;@Y zsn^uKXpArlagst|a8ga2)Dio%VIppkRsV+(4L~5u@zg_DoBZdh=zKx zPToeZLKVnaP-e##p;@vR1Ji<6@88q8U7e`AC5L2ITx=n9jIFl5k3=u)`lT*V$$%%yenL66N)@k7-Op)9V#53H*40rU zCE{OsEm|Mr)ktXx|U)H+!*%kC5O%mk)eu9Fr`+YKA9$*SnPTR_)# zP~Qw3AU9ch6so`lKnR*r5}_z!LQp(7(M5m+X`M~Pa+wi9PC-A>dx%tb$L}hdw=SOP z67=9zba$nnpPek~pfawPrH8;P+{mwi_U^*$-n7+l#Pfs)cP2K* zh6bdNTp}d781;)MPoAvPvrByUq)C9&AY~-#``k>sa332|n;4aUu!|iyjmUc|)6(9{ zId%IQZglR2v-p#}%dbyMp_^BjqjeDv-+}`G{TEIKwyVoG2{f-C06h|B=ry>HL%QET z%raJv2AIG#CJXecy?pO9JUk65X39iS(OeIq_`Q=^ZKBOQv}adOV&Ec1N;NYHZN?W_ zss%HJ(4sXh+61JPa#fx1q;eHYmKx9`s6!~VB@wAo@~hP8$*@MMmP^AeJh&oA4 z=cjz{Jk0H?qNNleOy)2$?|$AHQRfKLM%Z+Bap)tI;? z@P8>MqF_TEsF;{2r(PGHvosUfV)+)FU$FXpW*yup>(soPO9^b;f^JYB`lzPm`($?re^uCYq0+F+$uS5Y9Ge4}os$L?zS2A5X~ z$IvpK7$bOhZsPun^q|A-kXj-4M`O$;fTq3+Rx)GuxA`$|H_fBSWp?V4A+)X8NP?c1l%!fa-iX7Gn6Cn1F7a6<@{ zXaF7FR?>X%V0KR%O-n%{X*tI4klz_JW&OhKeK#tL#&=(xH$=+bl%jpWF8W5|Q3q|O zui*?#nIscgtudS!=9F+dRTyCX8xFx6r^+6b&a!x2aE?o*AWqhS%`AG;j-xLq1W?2L zNvi+TKYzTm3OY}21~b5%p3!VB@T`|<`O^O z8ioX+s1*2UVdo(_sp|DtrZo4JBAhSci6XceLXFPGej|dP{G*6_Hf#UguY8heOu!E) zI;T_M^36$>I6kmD=9O~8wBVSLkP*W<0#3+*K!!(jTnk4AsrP(C6C|tA&f?{dAI$D) z6MRi?Nf)8?@|+@aJ`492RXwXBkW@ea_Ax>o9aIUGiXumdPLZIZS_j7UmBrnQ>VbGB8 zCXqWV`+etc?R4wL0HyDZ+;qwui2ga%zJG6O+A>SW(C?qkoda-7RbXf>N4rJCO35MrNVHs@dbq^_Nec%WM%- zf~U71oYyo3RVq889 zm;)FSb&jUOS+Mo9{_3B8|IJbm7GNeDc?a{d%w@6ze61rr0d6`ZUa=uSCqKoV z2~tc6Ku~dWOHg1@;ohPam{YG>`{tRO@=gipw&no6G&E3Dvm^pvKE5)k z5+F$LKD?)Z_Bw(2UFOc09=0306E7AWZg&ih4-NP5gRLVrXVfgBA-S_P{o~h9mt1^% z$uAzSQxXVc|GrWC=U^t> z*Z#4k;jbISE|Ep&lRHo6Tme%-S`YM)i|#o?Jyb6z-ZslMJ3qJ|q9){YU@tFFyG1 z{_>rV-dl(-h34)`tu+KtMWb<<vbA^un7~9yVCqBCOkbLy3O(I@;;aA5?t@;WZ4<7G`) zf4pchZ<^SH*}8i@H~~9+m!!|DME<bcgGUVKUx(&30da`od@-XNK$GhEGH>a33BdJ>teWF$-6Rq~AWrAC zX36NgWci1ZG-ImXlZDZ<*5~7AmkgCz#{VcH1g@P$fOk>y>zqjY6lq@h#Vg83T!k!`{+S+PuGDhJOvGKh*^#!D5|0Yz|&$TKl$*jS`rA?>UrZ|Jicmu zrq!vrh-fmvISWBjTgBDfS7*}9uwq)zf@YXDUEI|<^KkO}FCH(Gy40Wq#1G!SKd%Wy zp_`c#R0%3HqhS_mSPI^pOY_l#O510(M4>yFfCxCmyw3r=S4}Nebbmb)c^bgI;A7`l z+IVv3YmQk=woZGZ2-KN;4XHa(lJ84|<`3ivBWH_!*r*M%g0ojJh1%Qcyyt#uklp z)GGA>9hbA%pyEuFsCULgp>4Wp?pQ*Y6tlUj)JFyp|lXP%l?0Ig)+v za)9q$2Ot?iT&&g*p)$qh{IoN{L{Ut1-tlK{@2nZZpni!aVlpbfeTszOAYt<$_ZHAR zM%@c9suCw~EWGE6T;&!g_ofCX;PqlrX+9j3It#u?yGQve5ZcgioJdQyx5?jLX z*piGq{^D;olW)KH8H_$R)@NRjQVh840$(OZrjnvfxhkXsZ}B#KW(CC$(r=m{WGz_A*HAhax36th8k3fN zf00IoeoiI8d!+xs04}vvy@>N&hD+3U5;mg$F36Y&6pj#NOyy0s%bW*lwEek(uexx% z?}0t4jz^aKkGcCkO>l;`jfkYIpW?I({P2c$d0wsGyT_9%A}s|jB9<&jKrWC_1(Lmp z00{i73U8mCJUpGxR8=eJ{F{sB7r*=57x!1Jm&T-(gc+#j0)~2aB4JLcT&l!6%4~_* z^Avvl={KJ}ey+2HnazVNXz$-YUC<;8c8Woa%xDn-s2bc6B#65=SLY$Ucdlo$nn`PM z1%j$=bcc|dn+L<7%9qUsZ@pJnxZSDS^TNa91xD9!r=+(Yh zx!2?=v^)Dh*SF?|Aby-v-eZ1 z|JF_(Les8eY*nZtPKl6bSIdU_c!|%xUAoR3%ub&oG_wMygECy`>_j;*fcwkiF1?#V z@ATybNnn^)Q`PWdjq8a7y$R8P``^?oAOBy*7SrX@w}Z0|7~d6BU)xZ8A=>eFjqXJw zj6UKTu~O2;>~<}1S0?e*3|Bw8FK;cp3egZnRH=kh;8bT2gg_CR&`2S)SuWo?Kl$-H z5AM}uToZ^=KZ)}H{)b=u`s*hbQmu8iM78ED%30L&r?GnO^-AWkK5MGeZyWj7ub=&DEfIOH6H}LIA32I{W-NfBSXQ z%EDQJ(zLCSqQT5~U1WcxGr$Y02EDSBpxntupF?tVI_q$p0eJ7xjMfOTZC9zCmC zOuDiidfe}dnzuBr>4cpJ|9BNDaWbF54L|}eS6L7Ft^;r|t&WbXl~Y7j={BELD)63d z-BW>ky^6c8+3lMvJF)$bh7j_<%*?5Y0o6R|;^Qx#He5I>Qi6(m5)$SzQhp0bZ%{VbdV?YmdnnFC0-Oc5 zm?(`x$H0BG0UA|7`pAA%8Z9G_9X0jBW5_v?7ex7^@U?>_Upb}tCUJjJCMFCXEu~KQ zm!!-KD)vqojhDsr;peE`>&t=;tyZ^@{eDB{`Bh7sEhf! zU7jxF-kksK&ptYQvDC%$M2kv9E#Nbk^Kx{=OzQ`L#ttDO+U4P~S*~xf_qSkpmPa8}2e`AV7Ag*-qX4 z`UJknG|DQ%q_)l7>q(Xb@GN#21eRPx-IHrr%E>?c?(3&b9pxlJ44zU7A(-`qk3Fp4 zeYgAjX92b)?b_3LP)aTV+2HbYj#>;JC4^;Tmrciq!L~o$T`+SytD0Leh`YHGXN!3* z)j9C|va#z*tiQVU>0yl^9H;oc^O*x^=Dq_Edy52JM_WaE_|E;Li?5!q zeRgK93d!=?o>CG0W90t&ruMmv(jOS3{3+nLQ0+vq=T8*MQN}U-TDJkK*cSj50rwCB z7nX2d7XA+hcDUAPY5J?9Jf1gRwo@2t`x_lUmP*}^ERmSOqvmdIMvI&m~r=p_zzg9=xh{Jor9>%eCDM50BP{3LY=d>hvFe_Q9Wj@E~9*INww@p1SPqDC&=|8QAt?y}xww&Y43|@|O)L;K;2S z*gN~j`9%0$NpV-u%|z&)jNY*N{nr;Cf7L8;-WcjSxTV-Ox%Y5PeE$NOz%i|ZSC~j- zF|E-BYO*kNl59DK5l2PbKVQ%zK%;#3+_NEeFAD50*893Uz9+e0udLf?f#wJ`kASnq zTwtI%ak*SOzjtBl_pSp##pQDC4!0!qd*^C^!HB-fh|XQG3;OPkTR3BeiavWofJ8ib zc4baWDTu%I;Jmkd+j!q@!>^r?%$JY;j|}l|ng;CubF8YFh&Hi-Kvb!-%QgPxH=ic0 zTSzpBD3VvA?k1sNkrf)=B?(bR43ATK$j(MFeuxH8QhPUFSzvg4lfpQWHs5g{%3`N* z2ROUrJ01wrfgKFZkzBI*xEZ74+En2sM^rBOdzSBWICw(}n4rp(d$FAvN!xFaGU^@BGDkZ=H$-G{UGAok<9R2m(=c#pH|x5;`mc zP25ccwRlL&_s*+#&%(?bYgR&vh!Y8(UEO4QVd+wHhFRH%>#HqHQ+4tMS-2h+% z5wyeIWFrUYlJJoMy!rNVg7nFGX%w*0OgCad9 zIF}!?r3iOU#Jl7B-v%g*fVxSsZt(nSl?){3Rk(Ks1rErgy_ss?F*d-*TNmx~4Jd^! z)~mLSCV`OTu;lvBzk2%h)3sE=5upmHm@?$sovHWPngk*2v(l;!`6}I;=}*& z!8`BG>$*uHQKd?dC@xi?hYGcL1%oxsjPd=m`rp2L|3Cit{h!`DJGCTjGpmBp)>yHI zhzC%;7E}&G9dr?DUZRy$I9!E6MXa6s>PL@G&u4#ghygcfHFUNA zgF#`D3y6Cl9-Oh#9KFc1i)GXHY9{WlNdx!pcrzQt^j||d*O5a`=TOqzoW5GO$^QSZ z1JH}R(2SA<8(nfh=`2VQ0QlimE{qIg3s6*Hlx}QFY{(JX; zDUe`DC&rTe^dF-WA`gbq`y;0pnCyPj`u!B8jU(748P0AS=jS$Mtayje>cuWIkd4Wa zoaQt$xCO*HSC8iLtp!6|%__BIo~#NXsB|YNGD+YDKt2h2RfS3`aVj7dLt33``smTw zzyHYx|MB1a^e;br=bh8)Jfsu0bF62+s?+jJ;``@|fA^F3|NEc)^xysXy+3>R{s|-7 zrMFG30hUxKD#=q9FJ>+OJcdL_aYsG@5fKqHGqWlP~I^mcmqz5Y<4j^CR;ZZ!pu>#T0C+t+9{-5|%iN#)4_g9&j`VNMS!PF+P` zw%sP^EXd(^!NDyIXMDVooY27mI_3ejx)2UoCvPmzH}B&g4iU0XfwXT7+QQE+(rb?X z6F{-5cT6y&Io%ReD^H$b$L@)OCG-!^@blk)v$lnL`{8~2k3V}rpZ(L~kg=9a&rC4k<(kX$WBn1wf0VxWVz3RW0;G(hv zAoOj>GR8}Zjkn~u`DlZr8|MJ~ZvMVeF%8<$6Bk*2loxL=YC3ewYJTqM&@2Vcf$k0> z!5m}~KoUH{yXrXw(dB*Kz!C zGhA~iMrK(uclRFtJ^@(BLHPc5)PDTU}`? zzZ^gAp6wL-q-68oH?0I?5Vz_syHSEUe)Y-aFCWLH>q70L`}KeSqxosTs%7UG{IA1JgO>Cr^ zJzJ&~>%aTer%TSG)XpFjabvbjLNDr9Z~$aN?d_zyOAs(nasxWP;PA4c^xhS6z`2Yn<3PeZ8rF7EE?+_Y3b?E<~}hjbhY zlns#W?@#W2MtVh{u8LlC|BC+GfY$5rem(MJU;qz=e-?eb2^I*$z?-0eAaGbNYX%o_ z0maCu;dzw*Q{Q9 zxmAw0&QIxyq*0$;tsxz@nbNJ+erE@K8#@4fM|WIiT)MgJP~xOkagb`%tHvQTOYTMF zO<3P7e)C%SK9KicwuInc==U@sL0H>X2EL3UJ@d(?H_A9kGN7#Tvu#>RpS&+S;4o}zuvVj=x#Y3?JqrX>gXaDoN0<~9- z_}|AOIFem({K;%cmu&qbBP>V*oem~A+>jD;bv0o|hWP9!?<^jk1zHP$q1sexJ$!Uw zdJfz=H-{X#rDz01q^c?iT@;~aeZnw@)SxCer%_ykh7dX%XpEXqkiP0Nuu*#?((sRM z2}*PlpYh(Qe)O;k>+dQ!EQhLQu;iKkuoq(Ig=X-}2!kFN8;5=0UjDBfGv2z;oLkNq zBf)o5Idqf3PiAL;1LFfbNIM%0dv2W4Mnh&Y0Kfn$=xj}*AplC4!h(?1{Ey$oU;OdP z^`vQ1u5-H)Cb_ZDx!%znk)440fc!y+pgDRqezAdZ;Eudf)gYXmp~}ru_p9ZqNoW&D zadUSQ#n7|5J8oG>GZFWh{6OO5|M^gHyBmAAvk5TVdN+_Re0rH;j$BiFI?Lvg_NR*YkK6+e zzhwyfdVOe2R|xjeKLrA_{^7z(>(iBlOOgNdUw;?F!w5we({+=jgZj{A>e@4C{cRBS zWE$uU6LJIaQ^qHq*!xiU@2vxdJ2>sVc_AjarBt}#in^tJ{+dGn8)X77lLH*ldMXQz znG8VdV<5PTJ5xJjyszs&dwY)NnbJg*REi!@o7oCvMW6!dbjJHLBSl3;6mI4zCQFpH z$yMu@Yj>#`s_4n-$y3`lJ%UWS+sb`e-|k?#JRdiyv~p|Jmml8OM^#(fTHOFQ_hiZ3 z4BlUmK2dV)$)k#E?yqH#9N+nQv5(zJGUmk3Yr;ao#5UV8mD5Gf2N*2^Mnt)9$L=!i zY-~A%And#?pYW1d!SOl93+ybaV&*D1Kb@I*3~n8 z^sg)yjj^A3vIxC>EibQDARN*whHi(#Z@Q-aW>kQyWiVA`xFdy)+2`)YGH$6F741=goxvFE1Z)fFzKyHUlEuF_3J9 zP=SbNz;trAWF8Z*S~k_azx&O%pFiiC=LXH3J;?|QkTk%ABl)jKgM-sHy=Ej7T zw@?1%H-G%iAHMwbi^uDfpp-yWQbaZM)B-6?A!J`WV*e>Igc_8f2);Vw)xUZFY@RM= zXpvpd2zSd=_&%zqbphtVspmWPm7}Y%JG>*NFtcs;?Tss5haa*_lc%o;vY0ZM((5NF zdN17RMVSo61jXd}^7J^+geEi|87FMv46wPF3@<~bax1;>5xLW{F_sxyudXov!_)Ym zfAzT0x=nzmjPCCRaAEX>t_c87ZgA`MpWU%b7V1W_Y&+j_fCBy+a5&kP!Qps#wg@ni zCgH0m7asDC=p@{Y#2pIgmWBVt7C;ord+Z?Y1ZFjqSrI~3>zFLJ-D#8BmrPf-Hy7K= z>%T-hk&UI^g9D(@9IFP&00`V>b*Ms4lC~G+F6Oz*0&d^oPSQMizil**rXjCu11n&tSZs)aO3~RbWyK3au!0^?h9G0)fznQ>j5(x_RE99? za25GBZ3DTbTSi?)KGoGZIuK)ihGyO|GC>E$;w)Xfe~S0cWM<1+%`HmD;oihu>hNGXeLYq8=AmzcsV>&m^?Z=rK3J49H|?>$#_Sc-jUdW6+K%cH9@O#NUh-a!em- zGbT8M!rA@e@m&QLznD8c#%~K9)V*cu;RWBRz&rH@#+Wd4greS=Z^+eA>h2ZVV*MGS zgFw5G;xtIUXaq#V+`>t$&j0EcpM3HRUY%ObfX!f?@>wp7bQy7m0CfcAK2bgh`nvY{ z7t?Ecb?XlWV#zYu{r)K`x{%TE>}uUQK&WWeLXbNE(CrImwxsC2|F7rUSBhB`+>Ml& zeAU4ER(dy=%qI!J53U0+<^U6k+@idZB|)QO)tW;|A?##Ut8@i0?@D1d*>#CE-O({1uNqvn?jnFKD!n&H5M+7G+mOy2ZVPmJ9n7K{9JD%MW}dB(vTzC|Ij)*? zY2jIvfBKz$`8-@1BCQAeWR{qVi-a`E#JvJ$v?^WxKwnN%$;m z`Em}B6Yt*Yt5EmP1^yiO3v!2X#QVvB1u9wikU>14Qwzw$gI&5P?u=dFOEGhISzGmG zQ~llVpI`c{Mdj}BNRN`m9}Mnv>#L`O=0E|Q+-K{1qbPs732;pB_a#g6nO+5R!j(9m zW2S&RM6aS;~aYr2m} zy0Zg7O7BM5nZgRCuIm0wTvZ`q9g$c|bqbw9HDyC#LEA*{Sh?Nb$mp%@V0t^~-_;6m z6$n^|DmBg7Ozu_d_wTXxtKe<6MzXvb_>ePm;{xuivcK-2e;0A(wS^E{n}|7cA7;k{T;v0aZ!-tL1j0A@K#rCm?1g|Z1rYWSJ|B7mn^IctMp7Uybz7bP_0K>4 z)#uL}IZG7x1Q5y3psSAvytgubg){e00TlhS1B<___|)|##guYbVG8@NC_jDC985qA@j zn34kwcz(IeUa(3(KdEvmWoCmUBr&OR-VMK7cUVe6#mq#-2r54R`pOyFww+b-&ZGMZ zr07b0F;dh`y(EvU9_?g_?R@XFXYIMdRoD_`&o6J9CIq!?{sf5#4eMY0{>vwJ^6}&5 z!cHz*fw(XsnNnGZa*Via4Y|ap!f9oWL+xLL+N(uQ{eVl2e;+)**du;qs7K1+FE;*_ zQu-1DJYf1wC(fqar|9Q9H9+@4GI-NRpE<||?O`ia-C66+u;6{g!W^)cS;p{UbMpy` zJ>Ph5Q|tL74WdkKP#Qw?-lvY;e=JG2%NRV_>JJI-GPUCzkt(zR}y zdR`|_qT-%qLaPW}MS}!$fK(MT3n7e`M>b7lWf#t8=f&CJg}bAwW**xGmp^`M_Gq5w zT&HHO5>oP%y3u0Ybt)#EWQ^5bk)>;;fp&t;unTfF=|}ZJbg#Iz`ym}8zwg8y{m2TK zx$X{bjLH4cFUP%a#Qyby!b42o)%Imb&c_^pgLhaz{iW9w?%vT+FuJZi3*YWy7tWui zKme6uWFjF@y2BDZDVzpjm85zW^?&>4KR!v-%7v9!YLv8SmCjP@a2d&ubnm*NUC9I! zuXI09&flJ=nOr-XBp)<#*&ba}M+kp`6vEwAnLG)BgtzXW1tKMjknf&1P?#fII>wal zGJsoI|1Sbv7708%h%fG+&6S%{h&KKJIyXEHCOUb&u zl9JC$eu($bfA*$A`Iyb><#H*iF_~-CApG&`=g)2aIL^Pgz@-JLX8;6NiF#Kx$hvqF z$zYZaTRqNaDBTUyjD1f)3Q_POn2=3ubhj>ac%eZjbRtSPmaK}(Vul5bHN}#%c+lVX z*vH}Y;4pw2FQf#2#E{@tL8vR4ml~kGNt36Paz?gZt&UR$SGAI{zjntoyz+X5VPs_v5t# zwIsvr@~hhv`Yg-y*izADLGQ_>$84!4Q^}wK+28m2hT7WrDeETf_lp#Yr; zOwyr;L zB$}IG6>+t0%}}YRr+X)$<$Wjvr2WOzUN;b;`xfq<1)*y?K5eR}SDK5vNnrVlnlG zXnrj4cXPG_)a2?u2Ett#?gJ>>dNTC`8Nfg2MTcdxizpC6vbJIVTcV)C2{T5A1t+}v z@{)i1yRRR&+UTNnn!A9WOf{6jK+z%bq5OZ~p1!b8%i))mi~P%^lq#^4M6{y;oS>pB zLCV!?-6UlFYsnO^J5uBAJG`?4o9=H8nrC(#7Bv(mfIHSrGB2sHF1?H5Mh)o=6=dIB z*gyGt+%k1XOlaC{{9!6s)Sz|6;38+(DDK?>fo@#_fPq@*g6B)L(E!rY$-J6XSv527 z{f1Z92HOr0U)WUWKzOl39jMg7a>6=|#u#H&hc?ATiBf&`?Zu_fziX;bzgPTNQxE z7=e`yncf|`=Zp6J)?|swXc6#A)nd|q9OQ{69p6g&AjClHOVqMGi4KYpm5HJVGeB*8 zvBI=@`jfZj_p5ehOQyy)(zqd1)^^DCcy z{LS*0pFV5C`6>yua`#Tv*mJogg9?jn-4D&U*N5rfUb|kRhcJ#W0*e_IwTc^n6_Yz{ff4?A^V(;6<|%sAQpguG*M|?uS7v9T}F>!1f0s13JA@oNSit z_8D`+9P1Vi$y;wO&xX0$L`)64mzthKj zoh0D>knUsOc)UKH<*?6sD3EpfDj5abBzwASKYx7HRu6vn`LoN$oq;M26A6Ht8O4Po z=Ql%qI>Vg}Wd9fh7Gb*dE&(zG?kuAAv36K${tU>XsY}tA(Tr5W!m4{`iBzT$#s(+& zHcEdU9u+%#N;z;92`L%G#Fr=W*}r-BwDzljHUL5~B1w-PaTKzvyKBrlAV66~T{;(D zLI$u$W)3(IRF$p(-Go*J2|^)Nf@C7_HjFAGIr;>0C-i7%h%7@>LXcriqzG|d^OUQf zy>l{8&uTVRU@~`?tdC^Ysoc74M*qj;-p9xBNB;J849>khfi2A|6#Sp<88@l`jUn^I zi*&RD>sNw4;r9)8gggA%s{DK78h;xU;p*1 zuP&LwS!)>&qo9lYreFFgFtCr*n7&0FfupmJEI7 zL->?9ba&i>mA}{j2LVX$zu4hc=x{@&sB3XIkjb!a#*SAf6~1xX-(YO}#u9)bDe`Uk z0(U^LTxI#6oG8~qz%o%7r6Bb1(_IE|D<_Qk;4~E-&n~a>b|dhI_iIaC394)UzPi@`S^mzwwqr&RpQqH4D9q{vDJs`{eU)R#ts`Y2RG5TFuSO%tQku7{&XzPKy}d zVcAi$?8tAaefKeUQQ<6Kxgi0>X(%9*P_L}|lbb>!J@S}4Vx|qf!b!bz50LLRCIfpL zK~r3rdB$cZv8(sb<^40M(NuKJ0<6*}cq8H(*H!(C|7R$5Z=#nA@6x+?B~Mhz`m+KG zjGipDD#$L>^ek4Y@ zGNp4)@jrgE(El(q_?1Wh$6(d2?Fkg|`o;+ihF{z{?>1;Tn-IQDAt^5#cLSf8j(ul7 z{H;>^oTHQv^KvA}3>HEpheipA5Ybt}{P$n2e)Y%aNkeM@nY=X^mDvrtlm9e_=Xr7z zxa+2ZKZkRGos8iZUXk1#gPH~19rw@X(m7${`PGt|HNl{&(b8Rna0@#Cz*qosK#jjF zp<~Pm2zY`Ji&>3MF@wt$Y+AtPpH9tE{>0a=vxR~KS659};-oL;HLXV!bSPSGdXL^6 zxA5pH*`zsI!_{(?Crzbty6DRP`JQLu)XN0=J2%}o%M9*OyOvo%l1p4}o|5Gv(B*3V z^>@#b%s%<*Y4jjMp~J;p6JP{X(1WKXSK=gZx-J;E@D9H#JI-K-{*UYk`lp_wWknE} ziusJ6j6o}Kq93}eDttX9W9u|tf!_c28wWqBSDJXR2dhki65ohaz~mwl#NxdgKY3?f z+w-{yEMK>zS$9apn6cDT#PtzJ_7)p(V!eeNvgww&i3rTa{bW8DI+12|$niNrHxto8 z{@;OtEHcO_?QtNJoxTJaNHVaBDai3+YSUaePZvLaG+Wdj(^@3BNtYXEr?l*$Hc72Z zjL<8a)OV>zCcaIpXH71c27C zPDmi&Gx3>Z3$QF+4U(`IpBy_8oCQ;p*mRQKe&)u}|7Jys4(8aOxwM(HS%N{u%w1+5 zfBsz?&Yw2&)w9*w7*h%=K|)qTBz>b&o_(1H8;wz;umBpDt^@G`v{HZ7{p}wih~KBfRwfMnZ(ys zy!g?B+5IY>hU6)B!8%FgRsc2&t^3)fW$@P*PT^gqT?Uk{lC?N%5mDOP=d)93?$zo3 zLgqn*nr*qtD`!sXtiN!r>SSI4XBUvbuJJ8hvhQZeLePEue7hv|2&{h_|{p$Izzj)qOr>g{z zDu_+fbwoP}-QGUC088H};omL^y;#!t@-m}EwNX!)kStzLi9$qhCSi9{qraspI0C_v)r zs%f=~=0SNj*NR{V1~o?_ZHV~QDAvIb?*`%RI-;8>J(&PseTS&0Nh;7Dp3Q#;$t7y9>M`0!(uumfo|bbNH^x(9uM@93We9LOZR-5^4{^ zJ8Xzs#~j#_Ge>5EWH z2}-I!D@X+dKK%(#HZnStehcn4`Y;Ggva)2S>1lMMktT#PSaf#}h=2Ls`TyAN}pJC2<)m%-;;Hs{Q09S`5 zvw9XRUY%F%pWWk!^ETKst>W4mQ@Tmpx-nKMB1kR< zNqG0+iMz9!FHOH&Vigf82*e_ISJb>^6@LQEaNJ^anRzSX(UJ&4!o9juc!I4QPgji) z0Z0Y|UCbqsDT4%9WEOA02|(V|Wh(#C$(eWRcv%6!5FH7*Zs4^-iiz2&RTM=~@j?FJ zZXDjG@;^HhYyw!Wt#xq{@Uuk~08i*FLDA7(UsvWn`Nvdd>d3X;cbAbipK)_Hvy{

    6_=x6&8e0R4dyba%^4*&H+$?`=i|&tc$pKaVHv;s(-XGxFBJmNdLC=ZKPy-@dMa-NM zGMGg}NbVw>OO)oxpTB#y@TIRWt01D1%rGGSy^p;L>BnoM?V0KrEBHpb<9NE37yTC9=^Er zfBfAut4^a=K`V<9mN_Cwop>@guKuBA*=l*ZWFFwjoYD1i9bJ&S zgK~!gy5-rxFgNoKm^}>@m??w~!bDqh8>p?M!_8A4<(5%s&09{K|Y4yP+CiG zGJY+&o8?lzxjRy<+U1WQo;^INE1HVCCk^TyMSSR$2q6lQ2^pK$nj{b+y{fY;KBKIul~;-;J65aKna1sX)XroY>`ESIfSUiA1=a&59Tvl zX)*#}gqWLq%F4M$BzQ8=pl+bk`<&R{2#WRvD*Zd#Tt^PPee-?ihe)T-Gg|eP=3g5u z{{u~oJv?rf0HN_^jtzFuwKBE&B+X@&`19X>^5s)oV_^*5OAJ}=Lpo?MFirdcUYhB# zr}uKBH{SFdhCV~igUy}9+1UxPa6N~$(3_P2$IQE z0=%fkWq+Ru8Fvd^gKa)8HmH768`n}Tq*Mqg42FHDQej!#n#6)H7vJ9;-t)?GfMXVw zEyQ_;t}?n2?t>U&OqQtH#+02NEj9tBX?;JWKY! zl6k0WcT`mskb*5gykEbwptLJ-Gf&Z4rP2^%D+E2uMvC=5bsaxuI&-hvO8Elg=cdE& z+PwWnqk1a|ckG{cg9{kpGBkvWOhEc1fm?b0#=PEQBm=#H!1$*f8~C^mgS6>h(f7$n ziylHPv(vAhHUIFhU#w-eHl>8V@M$>@$o}J2-~bNWfxW;^aST!jugDX?duzq~kCO!| zC1E75maF6)+3hX?xRoKzuAkk@^bb`)rIKAcV@z#A);G@a?FQfLO`2QD8^1sUH@Z_l zb-D|djrZytRt3qtc;`NZ&t2L7c2Ak)Sgz5=L_%QmN-FK{I7fe?8{FjsFwK1eoi|M? zwm;{v6L9}>g$ufJu;+a_f?qvbesz`B_5I&`@igj5YZ5|*&X9E!UhI8#Aid{Ksnsng z4SgY#dYkvJ>8DKZ?Nw;GltIQwHRl6&_3&7?_BDcJfH&7VCwUBrtMZIx|GtyaO^hs3VG7Q$ZiX=evu zUoVBEcIFT{k8loJ3Y1b1asZVPCLK;ETU1z|BFe~O>zOe!HE-UXgiKw)Xp$`l&_(}U zE)lBGwoyY(2UEL1dycE0KAN4fQEQU5-UE-^Jy`(<=im@NsBZHn8_^BQz{iLFQz_mZ z4|m)7IP#B`j4=io9RBFw`q6eiGI(ud?Ke zyQ8i!uPcF(Xk%P8edc`^*W9i+rbOFu#|Wf~vlFap{ac_*J+pnD6&o>#FD z?{R3l$wzeh{Rc9Htx6Sp5HtXHOWDP4)y6-5{dCF2mrvuDPu8Y07X_u#AmheaQ1q}F zsZaI$$7cp(&aQFqgIgzMtM)(qi2^G!k@6f~FPkcmJ3>I2B7zPCG}B;$+$GG!{c24& zjm(Zu4-OZJr{(|Q#ks=+!t(ZRU~-y!8>0qscQdbunXleElMl{A6)!4APwT1<<}RYr z-_N3_)9JxZOx`x{;Pw|`VfJJND1VH7`_07*P4~_LcwmKM#GYr@xzVh014b-(!s%zfMpZ&>LhL_vwGw|vnx&wVnRbP@b0*chr3Y_f#=JWf$lCy_s$k9LMsPE zauyVN-H_}^qZ@QMQcu#wrw|ZB}^rB3TGb- zbiIMPR|Jr~9S;Um&q;i+VJTYF^SZCHVLvN91CXQmH%q&sL1FOPsy~kxTYZIVH2IR_ zXA1z(g;lu@oy!??7evAgA+buN!Q2h*fCTuX+J1aLEd1)Ma@v~W8q~~HLRK{>s<3^i zCldjeeYDYC@=DfKQ)m;@H?W|HB%dhG4!7ftn!LUYH0Va1t36p3 zitKQlaWn{q_Qt*j5LDCor7ZsW z4_|-vw2ho4I1p*Z!T|!E)YaxRJTeD3uEfLFy}!8Svx0f2qf5uV)0xuTjjB(cU!}VQ z;0F-faBK!JQ=Pi~6mXo*XN18`M3(DjOupTFw*X$JQt(4a0LDOSKp7jbZj*y>7h+ar zJ$9+0&fv#_9 zP(=rW#1w^4Pnwo*&F$?IXj}$K6szj0i7`|)JjdSHus-$P0dZ%2eV;3EMo*CN%(dzD z^z+y2;2GUe$-B-7^dI!uINi^%1Bs3UrUVlLDUqQ%B;gcj1}S-R5fN2$7hwnjYbsyA zed2G;ZRRc8Mg&fe=FVVH%lryY!_Hv7_XZs^Uw5LtUsYE50)hU~1+?*oZqC*%D{jFC zp}$lFDw#zr1bo@|g-VaV0^Hv_j$>>ejaC0nEZB!X~}$ zhJ%~uYsii2tFwoT-(}6W>c}BZ8)3wgXP1^Yk<>mogLDu`swm)YcI57O`^0u( z=|)J?{Q4N15J-WUIjn74h2A@Vi?e&b|Kdrs5W)>UK~W+xG%!Uxe?~_XdG* zZeW3A5`AIH4d~)O>8rkjduzcfn3Mq7TlkG1B~ps$mtJGz?3}uW_0)x49kLJqJ7CEX zIwU*m2ke^<qL^3xvru`8xq$nBjjD0v?b-<-7Gt_6l}LscIwR!zjTW z4)<%2Y@N`t!}oGswuv$QGdXF?C2iq7`X{m*$!XL~JsPZ3J zs}GZBwicCZwEuS+2pry4NWv^P^vsdXSRR~%7DedJt96??ozS}&;kJb|AkjN879%O8 zB&z0yEbk92Y9J*zV)CnX(uDGIa*HMQkZCX zqwmxU!qjJhJ8VsFR&6cPhux1!d%<7!_U&st>?m?%@Ml+(CLkeHDVe*^f}e5u)3;_b zzp5DlgNO~-8_Ezx4TOlCg;9#?{Qf{>^B1C`d?x=PRk}rYQz0p>>(@*Fmrq*MXVIle z)+DRzx@p!qN8C4OkLk_65PW~b8Rk_MhzDAj0JUOKDa@03YqD(a0-PUEGr$K}Y;Ju* zh)NMUg={v_qaiQ3y}?xx{c|%Zz0C>04xB)S9*Ua_Xh7>|qGgI`AZmql@QI;Acb6Lf zP%>uHfr~37mmKq)&S%Hzr%YZhKfl46RO_s=sj!~RKLTNK^-kG;Q zx-Swh1I%NB0Wd>~?&bo^Mvxu2uGl7Uq+IXuF7<6bz-HpjwY15kg6Yhyxa5g*db4KYFMS zXI9y&7SIx528-@-gxL6If7y?n@~@UX@=}=ogT07-XS;EE2dOSjChU06WGV(wD1eo2 zahoc@MGlaa0EQ@bpapMIj7r#{OuYrhRSTAlDiqW(YdHV->&u^i{9O$9BWjAaF#<>_ zZg?aeuH_#@`TOyGgW&>SdtZ*+TsF~H8TVy4J(&Xl)fq095#Jl>(!1jYb|oKKTL4%h zWpfj=64a_fI@l(UDU-sHk&rtZ32&hA{e}_%-6)DKy?~3=rNMu zNKU-70lu9%0IpW8F_?qE*PJ`LLHMs!=qtrNCoZ1l3pOUz zfr9#`-pKxK633~a9J`iuMdiInpz*iZ{atSE53o1jaJO7UHApfs*DPo#78guse)(r_ z&CjZ)mWHqpsw{z$<1x*wRyxF8yjS3tEdPUpqMy5kfV#0jbS_x}&+?vKAVr}O1}5s` z=c_Nie)h#TPriG;Gzl(hy+z~3#@7D%buUpB zpJ{WZ>%VyWFh=1=e9v9D{8OXsI zK#D#Gt(AzlT0)uH}_sL=Q`7_^4=YW2oAaSAVRQR*?A>fz~rVl$^tI|x)C^?&+>AX zsF%yeUW2N^-SLL2+hxtGqY!0jXom{wD!3a(lCwz|{7zM5Y9{bQApvh}+tX^znD?xk z0n~t5T}iGrj0GK*%!qQ`x;t)Ps!TetY+8tBd9=kWOQ1}{wO4`rdV$Db!%af)>WeGCXrdER1*s~5z;anwI6|&{z({ixX z?I&M;``MR|Kl|dFCFtQcNh5ZOue}vWB#Q>5*Wz@4%_R&IY{inf*%$PEq{9ene$4<_JIZJKr7%t zDqDAMtTPm@*T()l5VbM6<be8nw z(`WY2A74ThF3F9eq?p}UfPde3*zG6ahxd+6OwNnv0GllVS70#*i7aY+xmvs5IRW41 z=zqF5cHmGSwYh|-s?hs!$hz4Kga0J#$2Y+N*mt?u`v5ep57~HK)w$Wou5xiVD}HE4 zj952qCjk*$s2nBhG!THD6tc9rlqzxdr!PZ8=6)X1id zF`7V>n%Y?IriD(b(S$5(+3x zXcTvWDO^U#zGTCogHFu5TLGC2O%W)ONxv!-6-8zkR25WR<>}RWozNsazgjt!wL|#{u|()jGRrG-SoIy2`i!2iHyKi%QbWz&9TIznKKUdjntk1Phlj z|8NSCqGiQ9ht;94S#`$d`Nboefn~0AmmA!!T$yZ*jiG6iyDP0$Q~}a!-Q_{z9q-j& zOaN|ZStsEwN^zv|Ha4OetlsL7p|2PfCm*-5-uVk z1oV`?sd)bY;pM=?>rb>a=otaHD9{3ZVQ;AY``|KGELF}4W_Q@UeIxqch(E@5KW^5u zd4*xt1_N<;Y;t6Gs)B4(i{W6Iin1_~-H`w5bRvyN&c0~wgsHc6y8P(j>_HVzf|;eN zo@Hijr|sy24_AlLkg|KzRg6S-g`@|xIJdQH%EQdeoKC<+&=@#7wJ?j27(K(LVoV)< zEuV!`Iu7sjijfW+PELyaWT-@sPQ@}dLfktN3`sIbliEKY`j#$t)zW-;(@^H_xRlN^`cD?k4Q_O&8_nF!U&T`Q-!lkNiVowJDDeSqDzT%?JcV2rl&2quzy z2sK^B89WI9t=7;hhNz)dYK#hG1{Owq2=hqpWh&f(!JpWN+*+hO$sx2?iqo8C4pO>e zzh%=80mbMha52xORbXR{)**}LA+64I_0fF&XJ^Ldsft;$Y)#RoXiiYGOG7T64imTo z-cEp|>@9Y6N#EA9Mz`J}*f_|3Je=k(FP=xs7i?{zj zrRSoY)Pv!`#ssH3%sSh+&LAwm-Px}^Q%`^qenw1c4LXGCaLZ=DLXZ}txCcO~$*VQb z|JPrB@ukTMbv2tMYebWwaV9#&NCV9QLL?=)30>hiJTTwV4Bcny;%?rrhufLmbbs0G z1KbE!%5Kwh%Sk?xxB+6+XelTjoP;VhflMynUAl$4n&xc_`*XRn3r($K$`h8o*v(;` z#I71nW@<=@goLYQ;|}I=J^FNrxTrxKj!cWZ(--w(cTEn1hbkTJ}+(ASlEQ zW}Qc6ueLF^+uiTDzqfX_zy!-CbGIvZ1@ehQg=7AWXXk*v~Y6e@>l=(?N`si>NKY8lbO*U!vJoTFLT)t|k4c3ww8t3r=SoI%t*bGrNNC?j+zq$sGpu41X{av6hsmf!7GQvVU)n{!pUx- zCn=Ol!=N!Kg248kwjZ8{2enCDDgjE0F}umeWP9$^&>ia@hOz<81CiVtS7N!rpBq<( z5|T9`@oz@7Lt>AC2r@PC-RckYm#xePhJ|HR_a7R5E)(w1czw~A{|jBw{!eqP=jSR< zsOPQJ&vE)!|NOO8i_2B&pc8b@OnVWB7)9Z2xybgrqQi`hsjb`)%J>f$BzFYO_+g4ka{OXghR|qb2#GVW(@IevMg+_XTv_)wJ zQ1;w+RLaM8{3a)F50u=8&_Q~_P4B;Cu=ky#uP+mSby-KzP|{91eyq|4F4w*e4|)i`upxw)Y+-^xqV4DzQy6|zY7b# zSK0Fgl@8C8MFQ9M`r(t+$AA=s7~z`!8d{-YS$l3Vr93)r-t&g}>IAuO4$5 zZ`4xW=qexUQol6>$G}Sjj+4cla5E?6YPr4xHT-bG98Qp9If$*QDz{PH(YBH7!0mfT zem5=Lg)q%f8n~=i-dF= zE!`VRN)LLIL-gMzF=nYB+9buLia7b|nf>80{>#69^YzjjsiV6{XV*m^MEpURut(=l zqwR^*PhWwXs>BqoK&6~t*J$B?4R-o$6hO7HTQfEG1}8Qko>H1R9W>)=f@``-|xGzmhQHn8AZmdeoi3Vg{JOos`R~CFL$e_~9Iblhu%2 z*Y%iqw{5i1x5V~VuU>P}?Dda0?{$Ow^>YZCdXO$sGIz^u#}NJmRl~dE4NVEa9P2h_ zeuw525aa5IK`HswO+wtz4gMM-IUnF`Wh)-uDNH-uEjO&CYLfi+lgIz^ho^u2%g_Jo z$KPJ~!orDEm8!t46Ul}G@D7aYqyO*jAgl-E^nx4xCW^8uU$~I?Ku16?2-sT%PvbTj zV~ZiklLC=E?AcOC!S9Oxv5j56Y4m?EVDe)ESsuKtdlS6OD9jyLADAJA!DJV7gL~Ki z@r?g8YN%t36=P+WAKVY;p_wz*ia^o~DGFRwvjpfqX@BgGpYQeQu+;!o~ZXVRd(5}`qcTi_1s({l4e zHQk#VLhT+-``U*M4D9+q?jAzc2>}tFp_|L~`i=l_%i|j!M(t$1 z6S})2nJ4Qu7;cVk99bZ^1qr~oluTBGu~^ea3?jrqrSulP1ONQ+j&+MRb-kY|NTq;} zyNhqekuOz=d6}ZlKF8le{;`Nj&2x7eJ>}FXt@(Es&8OckFLCypua}RTDu%Pjux^`_ znjV#lzEGO^Es*KH!qR7FTa|C{2rJOQvZ2l2i-EAz`!9?A*f0g#Y9o|dqJ_c?cZY!2 zbjfcjpuWo`(GPv{b$x(qfIvNqgA-5ajuYI$ejJ&LWU0FtDZ0XIyL|t?ymvoHdr_%* ziVCU-+%!;vn)xxb=6y2%H;Vpc(-&e_!{ms&ume9<$i?jL0PIg)wm)+V6eLQe)6EU8 zK`2mfXTJX6UiIDyPq1=pAQVwEH*+6|+l2!6Am4P1Cd|v!{x`v%!mzP&4Gnq z&P3Ub^77KzcX+#$IRH0PvW>bv3a9WACNw~UWcnTYnK&7N^eh8QLP?=G#0F}DIZQ&s z`O`T6+h2Y0^`#|UxJ!rIf*I2ErgX&gJPJS@=@<4|8v=WBfIK?$CF!_j1ge0b-KC1j z*X~lA$jy=Jo3sPltrMx#TfQAOqybKN#{i)r z+Zi!Mn+fh$>mNVV)6i6G;3;Do1(eMFa?f~*Yhf@4O8y6&p(dtm;Gvkc$nmb99V5z( z`-=D5PwxBeHW^@p|B%mEU746gV*poyVtpdbN9Xd+EQzlTiOl*zV;{ha9AL}ob#GDa zIPk^|#`DWW{72`;UfB0e3WqYVc(?BI;11T;3raRHOA5iTo^05M%hX}8vlcFL0NF?e zWE34LG;aZB(+A5E-{xM086aR-C$@U;58tkT{rU4H=g9@0@&5@h&~hJ{M`ZXfll2|B zaoog^p1pQ-KQ#=MVm=GVX)JZQx{DERXVw3@+W)Y91QqG1=V)4(@0;8BVRQg?;vIbm z_#9|chhPw(3bNtOeU}OR@Hzknad#mq744}YeQjjacYPIJz%Lc89*PDy;6pqM{^at% zxq&oF3%P3X)zj5l&wlsiv&$GzEn;*6O15p&mzupp{=i8gwFu2eeI9iDJWP==BN?FB z4!x7`%dvZB?1iH6KSeL)UrppeyiobXM!kP)-@O^8`4!!-4b79io9*@jats7K_hhH) zPowE?u2$rdvpFGR$rdWwUVd;-?}fFdWhEfJu4lPQO;K}CG0p4Qfx7tNCdq*dvQhZI zk#tz6u+GDSW83059#m21S8s&M-I$`kqB;Y(NdY zM=9llhjpe01iP6hSE}kfYQ5HzfBxg+FE7xd%7*cvp_d1w6C+!S=sn`NKWzF_Hvu;5 zxg^}6#4IRs1|Yg>??}Kul)Rt^QfBr_r#EF#>9oAu*~V@|nJfsrzGiW|IsltKEgVa0 zaA37gA_~AQEoQT=%Xc>;ZslQSC5~&@Ri0h07%FoRett5S4!&+Z4h%26VIO!wIgoO^ zzVpQg(Er`%oA+{p?y%&^I~j49rP*v|mW+thwaLGH{Ar^nUtP41uVRA27)UYeJg^6B zzVr^T6z>b}Wg$&6bi9z%=)xqSh>Y9zSYn!#206vX^a%rdV~K+;An^Sq0yx{BwYry%VTYzJ17rI=-ZI7bm4e(i1XYJUcX9 zd(jSPlI}ELJEn1wPdW)q=uYtst@%3(JC)XA+mkWe%wtLrA(<^E z^BkWJ3=F2u8)vm~->-!c4rreVNUT)Ooeuj0BflKp*UyFBO@@)^ybVc_ z{ny`py+Y+0T1#YBOhXTy&M-Xm9s}-lLz?iljtJs6rhoaaef9nu<^Uz^c_cr&rJ$f= zF|UJ)ITE;RI%4R?1i6cXZr!l~V(+6MoSrN^rHsgHQ`ZgZpaXpi)2|7IZdC%n!xxB% z$+|%g4|+#~|3OWF?FvYfEO}SWu7k?JPAgQ{w<{*E1kL}-Guxqx54eadmrJRt*3h_o z{d{>5!xHnao~@TjD1m^&t1JL*ecg^FGKZAx;Z#)LbFE;a=Fw~W6xnrOM=MwXeX6jD z`yWfix#6-|`!DrIrBD~eraf>O4HV(vRzy?=887fC-@D5I9At-wbE~kK+FYpAfC2}p^<1m%tW()lW(x$&A zDuOIaHJGE_WU?IY7#!$e>_vR_>|s&_Dp1KOaixY>YpUYq&)zwkx6fu`?lE_`Ixz{e zp*snT_m1%G*H51GeRtnCut23D^mg7Zg;j8wS_n&1@DWeQQ37bv zBACdL0i{dHi1{f5Ln+A6IpL;JL9HsiWth_~R8M2|uU}kzb7c){ld5SWfo8RyRh4u> zKLIK1J8Z)pbRQpJ`}v)!ps!@(7+G!Hn%V$?S*1Da26ffMZdsp-)ZO7Aejq^+5QK!~ zIhM_?cfN2ipMTvgp*I$AkDb|U?lFw%7}HQk2~!Br-SGoR0CGst)l>xQHdz?H-&jRwy~l3h1rb#@+%aFWdBUKxqt8XaIDxcv8#n3`2Ud&=B$WNy3!n zJhV0bf9(Bdb0kTYB?_K)``=eqR#x`O%95FR(=*MB zbyZg-XAv0$#NAZy*$+xg&D_l0fgqWPh6ySQ;cx&BH#60H&piZ<__#U_=}xNgU?wJj zSROozsA$pzyJVvrO~o{Me!A5qR-Zn-`iFo0_C@S^bm~?Xty5>|OedL#38;x=QSa2_ zJ)N)HOW*979AAkE-BAb(N}R4eEDa3x{BpxL3tHVBKk~6EG>!Z*1Rod@nH`>1M#G zy`ohlM8b6u{+l1ppKs-*t-gM?y$AyBoJg|yPXS>y16T!({YLgdm4cD;ZS@1H6Csa*Cdpwas-(pDZ_|MJ(5 z*N@lHg%`q5$5qiRnzdfjh4ypo`f$TNZSJx>(q3t{tvYZ{o10Ft&w!|9B4H2#mxiIa z@kh$tu~|^_YH9}XFgDy7EwWj`M8<04jP-~~QUME8-~b0OM!-ZV%2W{3tuL|u`+xoB z)2DrK_kwgr@!rQ6tw4skmBlcBJo21YaOdD@IwoT8dVNC0b!h^eo@53rMX$E~*g0Ns zmxJ_f9i4ZeiGskk??*z%;uA%@JsAAW7Tk?z@a(OX8UR)uQ0MQikay%*D5J62hGhJv zf>oFH3!9)LujO~$>>Cg5A5+qBlahIG@oU{Y+j-&GqA^2ynODFa$Cw!b66dH`J1pndw=rF- zu3ds!kB#Tti>~D=pJ1EzQ0mA;EKjTjW0bhLuNS|1&pliP7q29h*Kq0b&VDSy(#5Gj zbr~<;qnBMmi}!o2?*8Ni8@Ckag7+VIe}->oiNRZVCH2Ng6(tuUftR|P1PLczd23n) zgBJ&gSzK#e+r@wU<=Gj}6~P*aH^vyEnI$`3)D1QF19pwl@MeRlV}bl@0{`uAsxJQZ zSIZ%)Ee7&WdtHsZL3jOjC6k3XB!LWlV3Q*@j1fk0!O|E9V=xO!4{CWm^q|5-;W{;m z6jKpK5^C<`tC#q@KYf2Cav49sF{GRpQ#!|A?E!vZgR^ zfN-d6R0aMz!Y4Jul=vO@?68Z5$h>ptSUr!USo++zLJ)62`~ zC>N@0}k6?K9G+dITtpa zs=}N~jONsrCXJG8Z%f^S1!nv)7N|!e#Pi;us)NgBQ^1jQE_vxNJtv8h=T8Usz1hR> zRjHlgthXr^QUVf-Olw1{6fse#$jWsL?3_<2%pTicobscG&LhMaW94O~vnJC(sKz05 zkJ|f&D~vQf^L1;rYubj^ZFO9)j}!v)&6|w!fH|NG1tS;}0w+$pl*R~lG%y@If5v$N z*?&w%VP`~Y6ViQ=DMM)%s-!ZI2Jue4ssIuGQttom^JjngCeoipssWMF3f(`?Hwv_I z(BtbEL^*X(T}l4uzqyo2{X%^Qsp0%%2teM0qM;A=0Vrh zFTZ=C>jw~*TVN3rF;zGxA`b9?%ZAvQiy_Jy)j2y0*)_r+l7mNL-x-#U&eesx0$-82 zg5&67{dMvFmq6&{9$-pOx&ekaF8Zs_Yil!~^&TWu<=)zT^q#w?7hin?|1#j5kbF8two#f3qTcBfzWjw8n>?81`I7Fjz7N#sY(aq&op_* zXOg*zny9F_4&Iq~an89OT&-QV{o$`3pPY88I(YtIPQ-b9xqlOH_y*{DokOt$@9?Mx zXl(auy)Lu&=F({z!hIV?ja=gx79|}MlN!lTnjoo)0#U_89OzV_VvrCe2B?Vx;lNIn za9h3i-KGBHA3nS6(Hqsd5Q8B`M1zqABa+|NVmRTk&HwRN#W7RF$pAnW8fFoktk+1P zXux*Mo6Vr^j$2R>A+sJ*)zgQt4>o@n8UUG*VWoj=K_(GmVy04I-rZd!w+-eWlFmoG z@jRMNGzbz96Ss{psER1fE62?9h7OYNCZr%Lam0> z2K0ul$@ed>o^P;~dw>4wdC>JXQbbjtst}TvasaB-z3AppJrympHUT24HI`xcf*e_`{9uevbOsS--B?Y_K6DrqfJyig#oXI%66NUe6>VI z7eftdy>9jYItGx(TVgd_9aax9G$&fex8cq$+1iPYk%DSA(71)Rh=8IsGo_5d0v(Mm z6O_QA?w2-lIHmjAFpw0A%M3Xcp+y0`fMnFw)}8&!H!uJ4{U+#XFmyqR8 zaNInH#>oc3LkzCtcaw#}eKJTbKgXzob*Ekv^q`x-P-nuVhz=LpG-Ff&0dw-hd4Tg4LkD|wwkDAYzib?$a_VZy^|{e%+xodw?6xKD*Ser^1EggL zAie&y+Nv~3e@5wnpj0tu#y|^<5!_O*(Ea|4C!5vdFQ4l-FC^*-MFoUeLi8Qk3DgV# zRn4hMV*W%(R6?2TPZCwv(%S}@3RT#6Bvy*Km0<+avLZuQfCRO*E*z=}BpeEbMiI!? zN_^#}iV^7KsgEmNigBf+B4 zNY{gz*(Q*#Gxd=a@V*N_$CLm0eRT24t5b>2GlJrr!JJDx8dq#lRH3Q<-W7K2TJFp$ zYbOuxD%ytuPBvg?(Ex2glE0M`VunaDC=I5n66`rk$_i|^ahFtJ?K<5 zp8ubJ!Jo@_C)_fS6r-3rii?(2vX=8>K@eHQQNn|Z5x)%AkIdo%2jEx^E&1-WWU=d; zP7jU?#TcQ>!}l^-DQLw4(n4ya?e^`m6WvwxDx*JUywp(YBRM=IKMvZEAy8JE>i%25 zR35ivgQy~^6g4^r?SxKEI+#byI%h_w=);Dqlh0r3fB)B~FJc$8?l-8-vJeFwAAnXm3Ifh^tkfKVbOBKKL2gsLFo{#rb@3KH)5 zRgaaL!HK58ngUWJ`)A|Oy4@=1bA^*l;tN$aJAtP9yv6lv#g9>O zKY(*1=k&@4N?%RxE-lhu%KbLKv%V|Z7=X5>^-FlQ0zlbKhg-7KT?(V! z9d}^HRJ83&ha4>mDTKDeE_jJ2@x{OY*~vZGbm$qWZWTgIWpB=GsLKjRFxv?Dp1gZE z;b2@3iyez9u5tbEbQDWSKU;b3@Qous(3&FL&JqQKPd9b&7aH#RvQkMq32f$D9C-~5 zt5dZhE;9jGs`X6-NI~dyBf%0*Pfsp3ecwI!^LLk@ez)1;gyLaz21$Y}p-HLzuUj$k zl{S}af1{*U6xD$#!Aed|5rrUhYcLr)=kdMMjHX{@hrBy}yh8;pd+vgwHaC7OmIZI+ z^}MyZaQ234gAkm%BLKZy4M9n_%FJn^&$4$y=nwO~UUw4v4XXU9ELdQkbU{LpEon-s z1p!~2Uw;4WGUDv(?=QbUzX~dH?=k;{eIRG(` zaiV0cgR+!XpT3Ttz&OM;-$m2g!->9kZ~f6Dw~o&pXa-fyX02v4Q$mHHkub{a zb@Cd6pWbK{d9eFhx@MLx#Yxu3==r4wsAfJobqF2t`$sLD)&LWX`*tp=N=n$dj0{Nu znsr^*M?Sxb|M17JUIe$zb7Ke)MWZN4O-Q-O%@+1!R+SlM|B<$1hoB4Z2lJD@56K;I z?pMfL0fq05H(78ioYM+ML~DVE>eM^@_g$C(j1bE}X%w1969UF3&z_xc;zeA4{q!n`OT3v} zi?w37S$$;Vz=I~@N9xVP#uTy1>6GaP-&XSzoUir$oEBa>Da+V;xX)Mliz!h&{G8b* z<2cM?@&k{o*k)b-$KRU>UIZNpFpwepJ5dY(%qfLY!`0(1{ObMF4!z1mGkv(6-CzAr z3b^An!A($9&K$^>PWd5bAVo!;Iu)TAEim?o5f%jIoFG{%3YnV*RcPb*$${^;+~gdr zR+o|ij_H~vUs4h8RKnFq5A}o7;BA8#y($0$BWqq%Mji6awiEk+*I^uqR-s#T7xiOd zZZ>0)cT78UWC6OYIhhV;#)4gGdw>Z7J%uWoHlWS{v__bhsX~)YK0R81Lq|&@MPMPS zIKNuG+}dBhzxv|2MeUNuYf}Hl#CJ< zvF*Y*UK?dUqI!4y^bhB>p1Bwyf@}$uF2GkTdVZ1(z+fKU`k0Xj5thcwVCNk8mIfbp z$J;pREo35~U>o{e)RJA-w1h*AA)_Vo`i5x7W5Y+b0i(^s@^u@95m6XiG6se7zKi0% zei5FBE{LiMNmVzC_qFA?4zB-f#=Zlq2csOHQMQJlu}v&qexrT8$qjCIUo`2xE6N{oGn{)Cr* z{lUotx%Av9q6&^d_m9N0_oss$90CYx^_5R~maiZw4HTA7hf{G1@5Bim;GL!hfGtlN zB1q!QbY-L}k4rWEY)>*d5qpeimWC%wlY~?sIhwbthwkdH-e0eH;n-V@5RJ?}@kz)b ztm)1(s}m3PipMN0RnIyrXo1XP|7`D<$z3!5ahyYZb4lzzhwZW}JJsq0(Z_gDi6#_6 zM@;le_a6ZbvqgVp0i|mz{nd$6q-}mM6E8q4Qo6Ixp4%5Mv8AU-gv3Mw%1Sd<)!5PF zTrNTTM{X#);qeQlPjgx*Squpz#6bn?E^#3Upx=hk_X(E=M|Y*tEt~)lUR{!R8<8jx zGOEMh>S=v@HlS?wcqtsG5K#wSop*QD|7}J1cmJ8Gzzr=F==)I69b2!KE)>BL87}U;X@^u6X6j1Ym#B-)emK|LwC(jDwi^w323))K9FO zh6K4m1Y%%xAS}D7KnSI1i#ulF$pG6N5!SZUtLzUw*dGwKFb9Aju9RzBy?@FNANnr7 zMC{EhRab$GBy6x5u(8&kb63uU7j#YRA5$WJrN!@xaj%nyy=S7boV`8j0osvcn-Huc z`X6?b1EcV-R;tn118}z1AM(hD#a*V4N!upYjojozfK5w=UO(3kwICEMJPs%^zhyDtv$W!dr|Ma5-Ofq%GQv$DPA;N-%ogYhD=l7j{)baFC+#BaWWx3tmd3 z&i+5e7jm@6k}wClKx{3huf||2*az$WSC4d!%asJj=mzcMe7S$jG+=kqwcq~#z@T7s zS~-xsjb+?atq`MGWO2wcGlQ|c8izcs!5!xK0mV`euph%do<>!Y7TAfc6>DyO{n$NT zZ8{01sFwirkrqYhP*UEi{y*D;$>ARI<}0Mg1j&6}x5=@TJQ=_RS(vC~+`aJt9jJzx z4LA%Tb*%I)rTh>vUh94pNrtPn=Pb5fBGa|2+Jjbqq5LAutm{@ZcB_*O^s^@~|KX2M zHgbOpHBczZwF9F`8+LQ&0)jKKwYMEA$v+cm$ zaSIOC1^^IA6M(Vncr!WVXx+H24S>uc#PzYXyRsS)+{xN+We+f*8*Jw60;JIP^eh8H zw6dsKx(gb=k`|^t@;aEia4QbNGQz-7ZFk$HsaX=69zeYB`fcBJt8E{|ce1|sU;p{jmyn>H zCC{#0OY^1$`t>UN*~bs^e+tW?n*Y?V36T5`^R^i+kIp$ZdfK1Dad+IJddbN!`%f-7 z)5+CaJjwc||Jnv2&W7ym!nEx}Rh4yjF<`g7y=^{78^9PzDY?mho8IZ^$tnXuY_WbT zuKdN6alvPBLjI}WwZ~d<=&5}a(=G^lb$|1_XHU1D zP%nrvsygQsj1_hrLHGjF8cTkedh!5Ofmf4U%U0c_>Qqnm;x#m6d9oP`)U8^pS2!jS zo-Ll&8vmzP)Ux*X6#WB~H!f1$s)KjYK2{g)9a2a7>g0kU8A1@%Km;bj zyohrG({S3KzcgF{Fx*$;5t;OCCk@qB<_xl zvv!pO6PeA>6Kc6oQw_>RXPO}mx(W9!t5+E?7UqwR0>zljf5x$2<-#aKn{6p>DGwT(~W+5h_=pIot1?+qfNG|NhW6k?bBU&kpKbv@r*^LNJu zHwlDZhRF3y`?H%2#8aOwP;z1g)Xl-Emv0e}IP0yChq2 z-u1x<=wO(|q7ad(Xz8|2061SOra#m`4#K;)s7Saw-td*aYvmIN4)b3Q{oy>We+>5j zN=@?qRerW~Be`0wEZWL-3Q^x(1`B?50sP~izj?lK-mN^usZ$j&Slp&-Tt}L`40KgX zYz$keRH=Xre4d8GLpFaoWjZlnlge2K_l?uvbZ#w15a#@2$c|58WBAG+eH{W^&P-0nV)J<^JM+KiB`@d)Pc7olPrv?{M+1 zR-JcZ7GsQ1=_7*))UQtO864CT5<*OW@@lmjZe*?b1=Ya(=$nr7Lx4R+@X}SZM66Qh zH``vFTXz~SpP$L*{F?gL*uNeISFhzMIeQsj>(syQ z{kPTqXRn^R{W5|yDc>)tPK+oFpW$*?`cs4 z-igHM{Q7xV{r7+Q>Ur<4Vz&)uWV&YsbctR4I%Y4|bsJlHLNm85y?=c@Wf&lU)4XUW zRG97XVE($i8v)$P!wI~qOHO4(O!mY!B0wy4=C`r|XiLDx2|@DTZz)Ca&Ia@y0{>G3 zJCVf+`cR`c>0Pc>bO<(QI~6@{o!#f^{VM&y?34fijWIArGh(w1M$-99Ir+OkJ^A#> zRg^QuN-0j1G!=6t;j4+isWbO%;3Jh6E4c^OAwH^<@>MlhfM;!x`zkRkPZ@3AHT_4Y zMe4u^O5*}dJww=9h#qP3c&$F+s21WzE8+b3zVcl4@e9hHy=I3 zFV4bRdIvh^5{E|IB7VO}TVEay9N$wd8D;LAwV{{pw^uO+fa%T_85CMiZKX~vtD6XJd7B(I0hSPVMMOtubDu!rI*TJ2Bcfr5?!`B-sEr zyZXm#tB@N~Jh5B-@7QuzpiY`Z)u9;FvG^}DI%M~)AC-E?7aVV9k-Fip*!78;YZph4 z#AudixC*6d=zX|+yynM`+?wYew(7d%>|X6#k7RBv6}Ye1AdTLrHVQiZ4aNPn>pE3Y zuhDE987QXS6!4Hvq9B^6I#unQ@4OqxT;*z55+7g7ya){mYIb0&dgF` zlrpS&{_!LC(1(>>`PeJWXuZX42*zsnBr_1k8`ySVyY_eX2Hc+|hgMxfw&5Pb1fXou zEk&@tH##;HYLx)(PiPAFu?5f&UOvc(wX_z+?VxQoa)+RRQ|Y`4Oh8p1O-#I;Jo@jy z|LW^=M0aA8ZEva`pv7>lq0Lg`_j)(Qwg(vfy5@O_|D5Xm6pl1Q3m;muyK3XMmPjC6 z1JOltVRL&+8t=Cu0+zX^H5Ei5##;Wlq>Q*bZeiKKu+@t+u0GqIbu!TTLVF{m2{$Kr zt*ZT)CHo+9gQGPAbz$z!(D?v;+3WxK)AwJzkSObD3zVvGLYPWvTUjm-ZaYg0H!T7+3#|IuQHa>)*zub<=!+SsTZ}C&Zes zmIQzb1czuw7{o|nbn)_$Za#VF-s^&f3lTun%v2RAUKcY8*Mo^&dj=-ccN;ih4Bvdv zj8biFMBoG-R6t0J7R;i7fe^2~;l(AMU7!cx5Gka@KBg4iS`R{HHn3`m^>l+%yx-7f zPlj3&Fq;wL)OAK7Y_0u$z546N>w9+5aoZ_qQAE1cNgv|A&VPLI98)9iS%2i%?Tpv_ zxZZSS@;U`T8oDo3C$n8y8$I4Uj$$9wN|HFCrpu^1l>rPKeo#~+_uShtA~?Fcb584Y!KtpU#N>3%J)Q2DKpX`NRM%F+$)otEDweUCdk zly~)Tg3h^g1sD-y92Y%&CvyO1{;Fc~l_vlYdZ%}-?K{yE%rY1eViZxKkhUZEuG*Ky zOQ5X9as!g?{-^Cr{@Qt_8AAwWA<&{*fBn4w=YM^29#48IM8zw`Xwd;GNj-WMOPI-v;q7yx+hm*D(kaM^>#|HFg4*ptiEUTbd7st6kR%Lk%pv!& zUhj_EbTHALhm#l!2Z%q?>HRTfwK z&TKXh5u*U+6dthqr}EK5edMm31@BhrjZRta>Fn*%0PM$Qmuy3q5b=Xw+Ic^<)^DJs zSx1a9(o%lilOJBZINw}t1 zbbwRmL{rcqSCP6FVZR=eJ0mOb82?~u`V0vZJpCRou-GfwtR zHUhp|Zv)!k1Fg3$DATjgO)1q8A{ z^nX1YXUbS1ji~#9uuQNy$>k%F@?lkb_zRr8#QiTX@Sp$bv#p%LuL+435)kFRS5;$F zmxWvtAit7j$6;QBv)&jN%m&+&im_1$p#5pP&OfrRKw+yS#zGSC5z5ZjR<9h3jxRRyF=hB88x%xx|Styn^Q3rhr(Akx^n~P@*%QV)On<{NRkPf7vMj21d8?qCQ4C z=Tvn+9U8cA6R$#;<8UnRI}*4 zhp0F=)*O!0J(i*6BS$?4Ly`A%JL7u*zLP#|VOaUKiqK5OIan7j-#@Vr?(u#X)Otj7 z;vk5K1|tQgmirr0EMHNDkCHEya$Tj1s$N?AFVXAJ)p+wA^*T4v&IDj&AyCysqX}VS z8!$;Pkx{sKdpXzNMIF!tO6&63{4|Ur4hxVWZEFEYH0xs@VbXOg=M;$Ilx`JQ5B|UZ z^p|JUEBF90gQ`-^#DpS=`BqnQ5~FbDy5ioT%9kTQm~1{vqIR3Lo>+S|3puCah%?d1 zs`?!q;n_}Qt2NtQUvS$Fr*JN9)=D_DG?yMuvrt}Tb@=Hu0FuH01r*KpkAxT*)geSx zJrQaNB)S`M-LjUJlD1Jc&hbiUpcRWBCU~b0Fmyd&z=^sQ0n!ANTz=K$QPFZWWP5+^ zQd*bHn-`aF66uv%t3)To43V@HDyeXQM4-25LI}+w%(5r2vA zm}zeaFegzF9SOpSKnp^O(1d~3n}x_o_UzRYVY?2OKX;q|@OZU~=T3TPq@%7}%sQ?P zMuF*s?XZ&sJb|wVFk~EoG3QS|qkO18*;2@AO@hc5ikgUsQ)r^Sh{VFI6GCaC>5>Pc zTV2V?m7Zj?f&=$h;?&VU@BLGb*FszSOi-iNa2GwKN=v$r%{o(65IpCr6VyqjD;Rp?YyvcIS12hrvsidLaiDIPRH;z~LfTnn_6n7VD> z9Y6hgiAFk~qS!^(pHy76=vK< zVs-Ynzx(sk?dlI-U;h4!7aO_P!}YOG#?=n0X^T`T1^tNqKk@^u_d|RR+ zrwsmz^f#d0jO+gx{Hu;SHHNG<5)~b6b~^5_;}2H(H|PNltN`NzWj~-wOajfO7g7$( zg%WC#W4|o+r!Ji0@;`p~!1vErYEDaI%DmI7YBp|?MG5RYOs*N4mxZEvczL|`bN*P``Lhj+qu zLb%XcEZvW-B{6oee>A;!YYjXMAEZI{CNsbR*C=?uN~=n0A&KFq>4>aYX9YE~F#D>N zX2S$gVBU2>Ru?$^r_X=*`+xoJrLCzek|HXaEH#td10*c6CKb1%YT3zsJ3iCxnC&T< zUT`$cZhNnx2veaDX%({RMOEer)h%sipet42{98ZRG?U_g5zIj6NgpgdX*R5Y?EkpoS3>4V0_y3@l17;)vLjY%^jnhW&i@ii0nU?-V2P{MN zKLm?55MzDvevb0-4CZTbonIp>nj}mRS7M$T=^`}%CJ>qshydwn9_EY^1OY(wKC+9? zK75P^r?GR<7%5(*6FLX4OcWZMBmwXL6OQ5fPq6=8UeFDQH@gTrj?js|xs$bD620Hr za`tfW3a1Xx8f8raR0nY#^!>B-$M3CW`@`DNB4Hir14CeM7E^zn=pqGy69r{N5<7N< z*{vh5OQk#Z4VPn6VCQBCLqPAWLSTk@wfuzk@pkztMfPBnyY!9Hu6`KuP5>&@ajjTU zX+D_tDkg+d#G=IL{prgq_nXgN{Q3J$FAfxEotiW4quUY`IyEo!DAIUqv8lt!@-JieuVT+UV zUY}iTqd2I`4sTe#r4m*zvbd9)B%Y*Ybm^=3z<+rdvZGk_0#n-qlPjszo6s0tPrr7vqC&^T`Jt zw$Il-;S&x}g;R4%k&NOjdj2d?fN+FQ=;RFSX&Y`FCH5L?mxG3v!zEj=xdlhs z6L-9>boVwna37WYSmBNDug1ck`H z7kH|en1~5XMTO!Mv`#Pn!KY8dKY#ke%iy4E1SbK87!eX9OQ;G>O0RtS(o)Zv9&bL( zOXS9T(~;|(4hl%AD*V|W&O4m+mLK(0|C??AFc|Pvy5Zfm@vb!hZRk(Cl&OM5I}~2J zLPi3_18Xk#Eau2?jD1gIVK~Cjga}I4nVXFS!yCI4bpK z^8_ass@(9Fsb~Xt*U#xc_rKio<5IffBbIq+0(1b=+(S4C15bnU?xdunN_ukQ%U3e zO1mF0y1^`HanWliU}Q=H&PR;aU^sWjyEg$izI%K}8vrJjbfFz5xJ3j%b~|);y!PsD zxY+9ifTZdkk9ki1J4paoSZc3#BQ;FxB*A5w0Ay1jC(0OUrmBPl%F9js?AvD>IlHiv zub*87S?TJ8A_lBis}Y|*?~7?Ste*X7H@xN%3>F!q$-rbpF!+UO>kT?vR*u)2Lbqyd zwr2}3Vu=9oWXeJ^_ zwxp6FSQH!3V|eN_B`*&9X&(QE?8n-#Mb`uEoM^ZUMwSI(k9C+nT$`_$uiw({s}!dx-3!yRgX6W`X@DHp z0ghWu)&`?`3Qut7R>{yP;M<52&J8?=^=(iIHzsDodRQ4mY;*-8D(WDv=p&O+fruJB zyZc`~5C8D5-@S0-9o>8cJaf8mvZ*|&%O)pR90tKee_)yLf>p`N%)&9MMcsB zNNNdm$Jf6x&*l)ClXhQXYNCf+{bxh&nfi=%m@duJ4nnGBZtxm_{$o?UUeyQvD2oIN zDZL?pBJ^smx>XrrWQ0XTLxh-8It%Zu`j6hz6)#s}N_g!om;$0vG>9WKmHtk|sk#IF z=DC`DJ@`;KnoTg&6)yY5vbV?`YB%HYD z&2EY62>eFYoRTl|bhuDDt0h*rrl-0*U!#3 zvOW*)hfBMPngjqM;=K!DD_J<0SgNcUI*r{l8~$s7>++V_AopZt>FCJKvvLtw*teK) z0BST+_JVL8g%fiM?(`>^hQH7dp7LqZofwlaD zMQ{BHHotfvk59~_SJJBo;UxJ$MW{guWr61^UE)C2((H9QKLO(n;l@{dZ^_sHGxwMN zM-CR2#M3hNr<7J{3RTh6jT62168o!r9`|th%LlHrm&z+4X^HuhT0z9z`X@iWS~c$p z9F!yf_C1ks*L`D?9`ssQMZHXi5y1}bFzNvWqn?4IZd-Qx{0#P2`~T6O)!IX6cf(ru zXcH`v35_GGwA_lMn@)vFDpkb+Ah?aeV5$O8Rb_Sh-KG26KYn{DT`x-WLI)u=QX|xu z5=WWy|I%M(DF=qv-Z-vR@J*^4nam{9NW8g5YC<_i^X>6obyxl0(o%`2eCQ#GgRD2$ zFdXLX4+5*b?zridTB~fgTgwx6cHQ0OaVszByg`gpz|2fl&C-+>>Jeg8)wJL`R}~-- z5jF-vxgs9?f!M0%C;R9u#($)RXp84HccOs?uuiIJU6!p*pXU zr)5M^X)-~{{lM@N3XTPq`Eu;*)cCx{nWcMeSC_V@?rGBxc7MelNyFq`-lO`(oh(cWCaJ}xh z$Fjv>XQPD=BFar>-LWpZviDf-QHJ|bh8JLMaXG#Ya}O{i_BNNUmX)XEV2^qLDS^T5 z^3{+JhqcaWjv>)O0mZR|DXB3`5S_5NtBELL3@L@iDG&q1Z~epHeDVA@Up@(|)4r#J zngx>>C0GipNp^0@D3sC!&rFvZOwz%};T5|#MYif+IQ7GW92}o9rE9{X)eg3@ADExk+rM$JqDl zt_|BX``q}~F}DG$_4@hCmnJSq_w;=GY{Qqqeg86C2HBc~tWztAHp8xF2}G=+Iqe2n zDtoB`N|j1GBz%qOv%`&Ao%=Yd*K3+(Lj`8*_dkvZ*sZQ8V?ND?K}7nitA{6U9bbI%{^|sm9kvRHIEVpO8aD^YT=M~rU2ssr zvZD7$@xw9i&&>p6iy^|xRFfl3x~^*p^Cx2L%H zt6!Y1alU5YX0z@(l2GX_ra3_3g&XT&QkKP?KgFGGwg=c%-8BDf3vQ0f{b7kti%BWg zrD?dwbH%dik0UNx88zEQZ%6{JE(OCxw5*5Ow;%$MLMRk)K-c*M|3e@!Bd)A_9#?<( z=EakZ(5<4GI1LuzT(DRJSF_ryuBNZAusaNs4R~+U{c`95P|0We%c;Af>fZpW`gV@D zKmoc7t@yXs0i?}Chw8jyX2TZF%)j4-Bo`1AnR^b8Uy&EGT1x0=+91kYB3N*%b<}PH z|JirXFJjlb`=5RHVjEqwL~2d-IyF1iN%pUn>eEq^QZqc3`9DqdKUM{_iNDRDAn%cR z8!q$oQ6V?2HCbmV$XMjwewZtcOu#NWzL+Xj7zO4E9eJ zrKDfdvEFyYery)Kb{TA_|M|%Zb#O{iG0{k8P&%Ol8~~cZ2vb#67e%ko{qBnwfBf#U z_dYNhg{Bsh0*g5fzRVfS(iSx`61Y}t{puDjt$R{lwTa1gCwIi?cd4>Jv=O}J<86t6 z?`*C94YmQvZB2%~Gc9eSlu^!92F)N<_JAH^e&m5QdU0`=!qH^IK?RL~^F7`97T-SK zZshdam*LrEH1QB8B2GnMlAPw4C(}E#a?z1>PbS&w0Q|Rn*}56Xh{@A@Nu`POnyT)q z=T9pFW)+n-|E(OP>P89~9Jm*2wbzdHUw;RkFa4MIZxLu}KAGJk%}b24E*8U|3zXU7*UXs3V6^YMV*Yj2D^(tr-i*Ab6LN(rNgrLQ8le zprld+4k^OJl5e1hs5*$6uD*GR-+cP?>4v@Qf{Al$3v}x1P$z$qroMbt$Ke|XS&p)3 z+MvcnL6h9C+#UZ0%A|h_4Zz$Mz6fO89q;6Yj^g}JCMnG9>bV%QbFg-7vR#f04$E5t zQd=UL{CkK*9k~i-?&R}tpP%>c(pH~+^L%TaXy;VDsxax&%!xRNz$si7`nE2gjmO2( z{V?R_U_{DC&i%=VTAP(oRCF!jyWONSdZ%*=d};7;EM&30qFV8olJv+C%`toMVh=z{ zvUh=*FanW?Gy@?nMst9(Z5Lns^)K$N?RI0z1%jj;RuNH!Dx8B$w1hT-ZHb+~ z`^t}hCwF-@Zvr>ky#){c6*uSs_PqXC^#YbP54%4^MRGoaAT{Msp*WFF;L9DIgqC)K z@W{na-s@KOV(p_UopUJ)zzjwuT=wE(c8uSBk+13r^9HN`5!3Hzg+Tr1RB|fJOqt>f zRMwDobTHx;R#uPHp=Im&Q=`xa?qd6+bkr(n=2)uGfl45;fKm_8iTWh>hLR8%p&Ok2 z@#~8}eRqE4P9s(VFD7Z3PiPH=5sT(O``fu!Io-Sd1iR4HaCO<8A<8>@+~oksVkLBU zb-X)$ZA}5oY-!dS=z4gt9J%8j2K#qwM+_4HniQ9qR2>V&zr5_f{{98sy>FjhzPv(| z6_8?}67z>bD#WSN0yF|O@N$xMUkE=sni34O*U@*4DuA(Obm0B#SS>JA|GQRw^?U2z zeLL^C>syL03sw1(P)HG0d+!&7e9NTKf5n0+jKO|lyOlLfkIpqNh)s@aS%s! z;wBUb;HWm>b@_YF>Vs#0-EwZ%4J*-O=tg^*f$Itk4s)L%?Tr9UII%z-;1%9M70HNK zMPw~-+w-5_(_cR77|zMC?SrZZ#Uji!F#5eOvMF*2{@gi>cTPULD&YoV% zZ$EqT-AglBS)dbTNE(l1JCK#v`DGlU{mcfs9l7@%(F0)D=5C0n0q)$=?~Zq^0iXbP zK>s_`(9RB0uZv*4uI=;S%DdA8&_E`0XiH9>Fc7MN{PfFj0(^k~{OhMtR_aegv_^d; zv@nT71=_&>3ZMktunj$EqrU3qs%WzT&_v|OY^kR+_W#9-bsvXkG;P>1UAdlCXHU_2 z-Lm!7Pxlf9NJ?2}fQd$ENGBH(4FVO5_fPcWpPh7kdE%gCVg);hr&D&Ir#e%CM1gsT zgVs)M_Z{2Q1|0K79aAXZ%%p#hE&UBnf?S_1b`0%jUN*fDtXBO4ipQ1a;5~&2pq+?! z@D7@$092(qXSRy{sa$>d{^`1dS%@(*Dfvxe8V{AM*|tNTC75Aq*Z5`^x?_5+23d)e5~rAc(|z~oNv`_5vc zpnnQ5$e+L?XS+ACwu+kc!4w*%F*YhnK09Z28DT`Y<)rOq9 zLL|Ng3h!S;iu*m$81PpHzvOCOK$&%gpSByUBIDwwVELi?WFsjs!L{h3 z(-@)E`)V~0MLQGE#l2J2h6SXms*17&s$}J&iX=i(TlVQlU^nA3evFKD{}T>!wK+b} z^e`=m*gm=b)0kwtOiB-eMk{|nI^n62N~aa+q73BL3{Dkbh}#$m73mPc{_;dFJ~+Aj z@P2$C8`ocnLYxygK+G{ZLa7Q5aRx6=RjG)|)SRh^iqpNZwHQ0)i%4qB>PK=uaiWXW zo3>;H4xfx`j^@2G5z?87B^5Iu1T6-EsfVR&*6_dl`hySGo5%X>KYsKeYc?aq;Oa$^ zi1BI#qf#lZDRYywA}voeI+e=cuCKYKT<2Wa_G71OS{j>7-sI%tSp(XH;;AdBGOAjF zh^Ycm2T{l-#_nXLwz-e1zk0xrPlDUNTssLN!1^PBs)}kEX;gL_|%O6l)quT9(QM zp27Z#ujfmn^Z+_Z0#5g9St`>zt0}fO^{^?wO9M1y$|Xf927@L(rUF!$6H|?m8-H?g z8BhQG&DA$A`b+V>x(Fgh7h~D1EJaEg0Vs0-S%@`RT;2B0US-mB9s}VNJ}7H0a0DS(cisJa4@_5IJvqM&s_T1wh-D8; ze-I0Kf2gEuAlKk>4Qc%K2IrsZ4m3FTi|k@PH6*y14~*U#ZRxYEu}_nfTIIsJ5K#I? z@x=r1*uj*Cq#7A`RO3XQhfBm$$$Ev zKl#7>um9m!zj)vTA;PJ8uOcvu!#$J|a(5UB*SQ{e__>_Jq!&0;JEH@p6Ps~jie?+i zbP_Y2JT)HclatHM)ycYZwmq?nUq3#1pc@Srst9Hf5$Cqi`bbqr+nK)@M~(Gvs&jc+ zl=+Lljf2piTcZPI2Y(~cTcT4S^_G-w6|X>ybA7&TD? z%*ikpP0Jhy>4J%1)%nY-O+dHR(@&pVhVCqi57FvsqR4ij1dmBuV!J`{h$qjznH){z z65yiEwV}x(qZ#6XdrHH-=6H9`qfo-(14c9u?OTW>c}P z9Qz0cK@=CLSC^M(r}sYm)qni<+0~{u6*#4^*HTuc3A7U}wwsA&z{cNmz~PGHwaAWp z%*4)r{MhijFsOSMt>Rl+?O^O(zq%Z`I=?iGZB36~$y!YJT3y7E-c8NqXJ#UllXO_8 z3Tno!vCkGyLPkY@Vwb;q|Mb06^@yS_Qk=M<(YXGBy`f;QrOP^O_FsyLiO~}PGczL~ zDfc%71F`DRx!e^w0l+@Ql-NBkG)hveyq*Q&_-F2qL|F6&-4iMds7N5hsRJLZ}6aVeA@ZwUs)me;D5WCJbgLM{YEN^=$2&@A0+B_ly@Gq0pt%3Qv zGqSJWGFWho$M>UZb;9^Be*f4pTovsCc!W^La3DnlxU?Nm$00Vd%^Dm^V#W&|QF2zFaH+K_?#6AQ_#qwp>l}{SPsNJE<&N|)84;Cnbfuh|Y zh&=jZyjyW(qB7gCU2i79Q6Dh8>M<#{Gzmh&Oe~7gQJo2ly=OR+?O(m`Pr}nP#7@L4 z_?086BC4)M7MGfV{eCsG81~YGd!B#&5CBQWiG`Vx1;OPfsEP{U1f9ytqZ6*2u98MF zxv4lOsw8uuZZU4jC5qt49$->d&OUOj9^jxO&@ml?ia6&;u1?mZImUa=?#snrzrQ|{ zOJ`TK$V?kU%n^S=$(#{R30zribmLX=y0^oP@5feaap(k5n&x44^&XG~ohAcJ1_EeC z3Lsix(nz5_o=@%umLt3WOQSO$Vh5y9Yy{%PGt$VYot?b@-+uSy^Wa_vHAoT)rSV|# zXpp`0;ln8Yt3;R7_p0uOlfan8pM6LCxhwr|X9KWry}M%ry-Ndt>fhgFSJlmC*~Fm1 zcW;)qb3Mq7)Eo&*gB1+hBK6!>|L~{hPtHj_-S*L|dhZA;z7vJIT99}h{x%K}7g54V z2ly&8@;P_}N$U}WNkVWN2|TX;%bqu>{#)!HW1ev%?4z_?+v+1UMH#QVLhWQTzowgd zO71U}SJLM-on+ROSoU&}J|J2?QAUE{p6@?6wO`y8N4&yr{5 z!BY5`D*1Zwzf}0Y=`Do-%}7h`DGD%#ZNJ@E?8DV1V)Ux2LRw@@D&gE7myg=EAM@gI zw3*L!Lyq_SnH=6z#S#%S9=}wMMW}_s#>u z`>iCG)~LPj#;e?qlL67bU%fjAXlw2fke?M0Sq(hk6u=HsaS%z%C>ywB61O%%dDX&@ zFILydv}`t}qOGJVp-L8_SMR;wMqXO?BCh}TkKb?oJrgIp?F|nA{I9($Xw&Y;-!d2c zx9GZcHvzb718z+Nu*cQ$rghuh@sld{dFTNEkU($0G;4jcan4;KuLs=N5BxX(O=-S4 zLRe^GQOOAR-3I^lx1V2e)kk>mNvf)MK5e22N>xa$?{fx`pnW6-1Pr5U8U9%^FRRSA zC|w)cMoSrzK}lJv|H8Fx0keiRghp7*mboK@F%%O9@e!eVYw!e zEC`O2K#f!|3eAiT_IS1V^Db~FdUW*lK$a}eY{@~t?Ycr)ga zG5=c=^*=RE*?Gw+JEte>wG*X9(kSH`(_HW>lO+i<1xV~>%k=T@!_u34Z1c0-eqtIkNSvFozCVgoW#mx)^2~v_>VRXkCLS)mADq zf0%Rt*@h@TlFZ}OyLEu;-N}Fc&1c_j5!lfPGp01~6bQ!Almo{l0l%&ts^)^%xy0P% zaaZgA+o_@^{peoX<=Z&{xFPm)cl@-ge`%Pe)#Wu;*aHGIxJk&~<#c8~@P_(Bss2Bb z^Z-;!r0eDM@BZ||_k9<1MOELel1Mm)kX6Gu$R^uXB)6Zz?4phZ3I;Dj3h%JF7SkS7 zwVY-uKPPy6<8Vj;T9mdln$q*Z3ZVYD+r@nBTx|`b&pDFFU?YFAS+> z+eR1=sg@h7sO(p=d9>R8{2nxJoI;eobDPc9J8u?SlEjj<_s9vr@n!u!T%4mnY7b3r z^evRRnarTbPkeY0i7_%7%~CC*swpkChEwNoYYsn(2MXO+UH^|HMB!-S(6sj;g!I1i z&a0|85t!%pXQzCB9Xnn!TsjayA4zqE^grEvPRs=ulU1lUdxMpEFnhH%TU( z&~hrnP@2J}HVNeu-ec_nvC6~5X}!6B2#vw00swwGBnU}N(FOLb~m^3p$N(UEqe zZ6~LkGjDDw=DUOVT>?6@?~jELM8rt5xDsVx;K_IA_?zEVwg91%n zeFe6fN^R_PfuZ`(*-DbdmV+`_8?TIHAd)8)IxoOufKIsH;Bocz~+`t!FJ==Cf_156-hsns>JI5}%eeEox4 z&;E-cZ8y7CW|>-MP*su^W7l;SA%K~wsxjW7rSHr^vKG1`(skXB`+9yv8&Kkq>&xA7 z+pqRr0CIw#9oH^j?*=;_sv+RK--ceCqiBqbTwTi9|M>lv7g+bwQPqT2WVsb{8oaJ+ z=`XD%e@6Xlu-9N`QHVWb*Z*quJmTuK1zdyu4__MWS|;t+^0_z&y&r?KUW3ojP6K$8 z^SJLzS-QJhMv(nu~WYM)#I*^ut9!WU#DoN*s8EDNOc8YBIm}`L$?IM@-}E~1nzw-qo2=C$>B`;4z2YklV=MdYr> zeTQ4#e5obuTm(RjIOKM=7z%scDu4F%l%FZR5pESlBlW76=pTG_f&b(8UvKFXSd@^d z6c*-&a4w8kt)q`T2d{ceZGyns+~1*Rs0`OJ%~uAmz5MMNQ?KzbEkZvr+S|4KFNtGE z8eG1%!lI4pIuyM#d!qN9WdVSM<^clLRQ*R|G?Mgcj9V4Bc=fAC_~luD;*g4e5r}#b z*G_A8TYz1df4}u-yRz@s4Ila5?)laGI=xaORqWNw#Ho?uT!i*f3@!?;LSeax<~(WR zaOOnWgp$cMd&Og}oF(P-5?5`1G}zuzKpVlK6k(2%g$g=^pRKl^JQNKVDrD@z03SV#eQk2 z*)tQk$(fuff3$r(4Z;OuY-q9VzbQO+cl-q4jGq+Z{Uc8RT37hHNx*Hrrn%2(vhEo* zT_|O4f$g58EqCbE!T$YgcYV+Zs^3IzBDp#X-Gg(;fBDV#FYL63AFO#`fHgZ-YyUqo z^4GTdmr?a!LHc&jtKL3Zc^;E8w1fc*S-nTwQA(TOQzs8Hr~-(wdiX#w!5e-w8N>&A zfL8%HvVV8N_yGf(pC^>$=aj4eEZMZMQqyq$aBZJFTAlLJBM`BQHEQ*bafBP@$SQk^ zr)xz4Tnql+Q@YLkz(noHF#yHKQzV35RYbC$H4;>T10ARc1Oe|mHp<8f`PK_uA;_L)PaEW}PD}qjU&b-r6K1X+Vl0OD>Huhi-`tc;5;J z>gb@NBxu3@I(MyWvNj5YdKaXZW3oEOy?^-2(-$_l-NorCH5r1I~5{r3cud;Tl=s|9q)+=+}Jy>-SgpGEmJ?4G5mVSCE40v>2vKBRK>lK=dNl`C2k$7MGzy8_Tn&%z* zTDnHdgz*VNJi0g~CkoEO5c`pcV=;bW|NXdo&(3`=SPlYPngi52US+^^cHAAxmsBQ{ zl|6!@`hx2k)RKj=5=vO+0Mu3}+}H?JcQp(KpPJ@-CjKI>|Jy%*@x@ETv&S2gKK5po ze&qg4$%7~EXvAtCPb@|o6@^W=nU^LS<*xdF7mh~u$RC~f@}o5X>9$GZ^{RupJ8oYO za0DEZRy5~a{f-z3BoBsJbmSV~%JGBR*?zz%;!pna)p!5>cYpdXzxnF_`8S`PTeqb* z>7Y&_3M!(LfkUrt_~nG^I;H)Lkp9N}POy`FS&TuOJ-AK1Df@>)TF;-lv+8_}xL8U~ zF;@=u9lQ1YY+*6$O>+!8Wq0#BMiAL*d?(;*Gg-1|xlm($2jb-~ALs{X)}e<5@1Pl~ zRE0P|V4{#ln0>JB-%rK4L6q-x-BA~RY=*PjyHfyBmB>g^okSuMPoLp0pZ)NsPrrZi z3^_W?;8dNcWCJg9m~&y-&H30IfFtYpgENF9*=uv{v{n|22(uezzWL~#NDM3VKRe?u zA2{nTREEAd4wu$U{Z~k62MWaFfPWrba@!koe_K9thmO+#4mE5u!xEj!u>R7vj4^Rpd!#c0%@dIOMu zy=rTPdw0CUM?(QZj5aRguw)Cm$p zfJ#-Uiiuc;F6S8k2Mf)~yRnq;dVHb$3Yw?^{#Qw$bncYQEZHWdN!Ax%e)Huw-+l4* zcb|RvRfu^u?bMaQG)v`$o4uCfQ_pVp182EIhY$5o6*X1MoRj2Z!>A-FUgRXc{OF;( zxAxT_6@Zz=7>#Icj`P}e#N_H)kcup`^t6#!>+LQn^&sU6N%WElL zqRtz=Z(}@~ft67npYDF5+Agq#^Tr|^R)r?=8%<1<;$sl?_dk0Y|KU&HM&~zS>zz-z zj(a8m#{d&YqhzvYajR~4PSvT_#eeo?+#R>M0buq)6a~v9U-)D7a&Mynkol}A0idAE z&gU{XF5exuc^=R@$FW{!M5bGS0OC02g3NrLnu%UaHm-SBr1e}fPAr35DrNU@;+AM4 zThT9{zIeXXOP+mm&bWH8rKtA}UA2O37<@bnQq`IQY2(d5opB@o7iihISqqllf`Oq> zY=30x=D>Ei4c-zY%ive`ElEiWLGV_D4S1jFRsRiQR zAJr%{Wia$~&Z&rnl=z14o}F*7w$;6-=NmI-V)fyI0G(7bEuOIoYeW{LujqU#j!)8uG0LCc+lV)Iu4wO~~F#J^mYkx=p$lF0C_iHQmt-_}rG$fDV#*uvO z=Q_<%DIP>A{W{4y1PUU>UxO5MtCP#E1pnv{U%mX}H!pjC@BAvLsv$B1Fwzz$Y`d1_ z3wZw!V2HjPzMaEc%Jd&GK}?$m1t1K%88Re_E2sj zqh`lu(u7n}GH}aOu;+X(7=#la>-;&U;w__@MpI*r-m(mE6ATuNJs4pCm5{CR+s*bb z&o8&#;}`wO7vH>uKZ)W9DuS{nO~i?G5Qh{2CQM!yDfc%=0vG+6jy5w2s7$sP+0w2t z1|=}Iy~{>}No`Ca({D*Ff{rRM>bUN#n{Lt4<&U8l!XT1MBE0-3VDRPG{a9LsmW2c) zIRx2oyz-^MR4~;{R$uYRKP7`-gQI^bo{Xd*hy^hUlhp?;8-MqqH;2m@gK7kggu#ca zU;5|2KE1r}gT>x^M>08J0x3hZQixrP{2wDNojB9)nFFMg+Y(O7>YJU|>nHiWtN+}2 zdmB^5shwOE{-UU33|3pQq>zR|NkM*U) z^=7Na3EOQlt0^x~)4efqo`=>nS!wk=dW4js)nH6qnL?DcW!Fd-?95qUHnJ=Hwan{*b@?FsT0z1;%~YbTxlSi6^8k`9wI@? z@Ux^bPF6?-l&tH7PQj5Tb{#_sdz1(@6)H^;oCGI95c|IOC-<)8^vf6WU;gEX@9jbK zYeVSmO1rfAI3Z#mNEb~^U}CvfP5@1M^GXm&zDkox-1x!zLDCS@ZAcbdr*vj5t0|xw z1ed)z=fJ?Y={lGoWCyM^57G1zvnO?aJMVgc+gB!;N-U9euzuy#BFL=Ns_HADFl83L z;fna}QGmu*Yg&=v6`EE}A{gVH4B+@iI=8#P$* zLLTb=WxR{b^-3E|W~!-{CTY&Q5J|uO>e>0Zb-mvI;+vP$(_l(b3IwLGQk!RdUt|iB zNz=1ew+dj`nJ}c)aAep&SgcdWCNvorD${LbzRKY8sbdhXCWT>1Maq``%RLPKrAlJl z5-e&`hv*a^JdBAnh^rx{MDvc*KM}FsuS8u;t8+*-Oo<%>y>A( zWz3?^t2#yX{YO#?VoW?({2P~xuq8?5{x$rXv@0ApoJpEyvWbeQ13HC?Ac*UA)ypb~ zFTHY8n@XWv&#)u*8_-~(IXvneIOa`jFZEQ}PbLGqv1`MIH{<~1Qa4=*st!;CRK=-u z2#>n{3t>oW|Bu1FNYL~E1?(8Vs+u#vjMSvC zcEEY#u0)Z;TS7<|>TYnRR1{JuNZE&Xcu1lc9LM`LUmj5_oP zkD5KDN&EC0Gs2h8CR%pvQi^JQNF$X^3n(;+5wxfl9YgHY#b{ozvKPO2sP|7hT1*^| zdLl?xcmGz_fg^mGFh}U$-w7O9_rIwehByC>lZnuc|IGb%s|P!-r63O2bSf%BA`m3> z>GRDW{_@TD7cuCG!ZAbxsZ!z?gCb@hQOiX)XEq${8IA7t%(5q-F^l5T_>|tc${FJL zr?kMN&H2_1{>9FySsHMicw)U0}0XTsGr9JbV*SduE zAq-%n=mhUOCIxn)As&EFrYR{QmWy|iq!YCmg+c>VHo;6+UwrfIe4_zpUwrdCvWs-8 zUL{-pBnWk&t|r&O0sR_0E)*lxu;Td+=5%$#i=xXs7!ua&(; zbE=i%V`e+mgHAHQ4!+uJB?BDO2E0+x=Rq9(zyxEV*S&7Hu*)2~S(}7q(M3xE+S9g) zOojR+$L_8Mnq&XbeSN%#QDIz6(!Q$i=Rlm@E#ff) z^9w|mZ5EF%;HK3^qGK)f05@(O**Ge;<|-2hCoyVyDut=~k7hH*d1vPUkh$kw^ClyU#o(Pi-kX zZ0VCj$no&c)I6w)_fAzj`}bG<$MP!#-Fbp==XBJ3fMI|j%)+f^yug}pJURVdoV&F)GVRc%!EA?)b?eq<{#b4YHur>kVV@iIXklO}7DW zX96(8M9eOXmFFag#ye^It-iRMM6OYpS|$J$#6X{d0LE1pXEvAFYiTiUDp&tSb51de zS<>Daoj5atXm1i^^_Op+o(Fds*I#~r5wMEr#CJ&GU-pn}S)@b4ZdLs@UeqH_PQ#a- z46obi0a~|>ihijM6A>912USe&I6!(xC~Liaa5Kn%ws1KB=Px88?cK@520izXJvEs6 z=e;~>q+vh=f+1QW@GA`-?Be+^?&~ieIPG6LMyFzCFjH0UTn^eyrw~(I5euT;gFgMo zMBeUJuCG{aU)MZGUN_0|#(w{^FKMYW+20Jz&IFb+9MICvb*d$(*IK$C9m6VK{NkQ` zaB6E@TI>m^x)3cG`&ylGe9w(10LQce#}LX39REftf(Z+8Bra-vy_(=(HmwN8`2ZE* zlfl7kjN*^ciUB&A%w=|b)sQIqipEbl%jQ$?Fq8RzrCVwe;4?Z+8d^X!&|Px%*$?4Q z-&|bA6?9E9#=d4mm_?~VOt1!!x98`m;II**xpO59LlwkG%kjzR9Et$p+Tn3`$4|dZ zijirLPx*{P=v(OlI8x5M3k|?b4VP*(rx}@rs1bJ>fLn|6k5E)L+@A{Hc$ct#km#eu z`rP8;Mrq|z%N0iRl$i;;A)CWM$Vf?!KuQx4CCmf?-PccEe*bd2b!T7waQWnAZ~ioj zr@CZRA;lg<@_$jJx~i4Zli{eQtk=O+TyrARh;A(7^Z*^I|CapI?3YdT-`eL*)N(U0 zf9ta`h3a2hp>Rz7cQBZ_-7Cj^qtMFv6{%)2DR4De$pj!kV={gMAY#Ez93Hx>Up>?_ z*{E$SZq1A_rs^Mc>RRXpiCv{SlK?P_mENp>KMClu_|BNMP@YSNgoNe#B{^R?2yy}HplCTItPw6QU zu#9TAkrc9@_kYYJbLmU+R`*CYiM&OWSXZ0ei;qLThBc?GV`UC7K0#=CJ9#zJ1~e%> zSY1dJBi~-FBhE1pqhcy>>hsf;q9z&z<{&EKosZDyPM=)4KYaP}s~7RgJOD+MYDktg zk!+BhAT1d$Q%9HMDQt2WXXYy;Spy~aAOay8it)5}V}bcyRrGEhp~ln`E>#peAz*K< z$$Lu;z>c$b#!#J#3IRs5U<~o@^0>tVfH~A+fRt1-k%rJ%NgfXIjr?Vp=%uT-6su~4jpe+_S{9_s^GZLHtsN^*_S!Sa{pciDOkSh7aTY%U?x zC`O3}Bct^zXS#j)(F6C;Z@q0!yso@+A|m3QBN<~<)voI%hM$~6>t|8qJr(Lw{@0Nx z{+$8Jq|W>6`uS}~F_8i6wgfEZJVPbfCVPXFilPIU?E5}FB?7(A4R$dnq%GK^JOmt9d&N5Cuu?~<9O5GwxkJak_? z?Z0>y&TR!apOhbFX<;dp4C9a{bA9ifveXv~yUFEI_P{)1k>sK5wZzUyn?*4Ez`G&9 z?L8Vs7xRVPI{(+*?`^5P-)sYr99X16B+W?7q5<&U#TXJ1st-}`asX~!;cwOIdxS9? zDJm)9%gmfZ=kwYnqML1>_6HSBqIl!HyJz0{tPpb`+&OlOr}7mebJ%+fW`sd|%9r0g zd$HvPCtv+=@p2RCJXK5#YGe;affX0I!Oy2eST>n8!vMN$SHnJs>Rqj^KOI}E&}2S8 zbV7-Dn-H(2nQHkA9DdH67et#hJkBRR){ya4*g<rn% zA4MERwlR1Q^e^5!u}>baPG#d$dKd(tQ%!ib_nxHHFvg}*+^rp}#pJHv{A`Ui+bzuc zIWE<9yFYpI)!i@cKeNjbr}H)X>Gax?1wj`rOYBE|z|jHpk@KXWyAAIj`c# z>cr(>fGQ4=auG(_s#6zU{Oa-QgOj-8g+m}|gh95!w$Y+Es=tZ7tL3n;=>cYo z`$>qw!A7pV7>0tgH+r*I03y;BI_T-QgXyZ8Nt{o|+KKkZ#KMKqc@)o6hD zw0#&XGSJd+nv;8Kl3~9@YedcV82Q2tmjDeH7n{-m@MHy5gb?!uS)7^BZX?AT`>q1H zl@oxxHdxnbaeNvAf#0Uk`;AWkMn2Tz080T}y3c74Mx)(b&$oU6kpES49+9kh00q3$ za#6@I?P;uyWxmHa;19L1^0Mp>ByF|sHrt-s_4HqS`$N#v^WeXIc4gX8l?o^!QN&bO z@E=ZU=*FiaUAK)#P%Yvm>(X=#j8*&Ub|KHvXJuC2y2VxQlNB}PM1&V|bd-S(r$-iPy*1Pi@$ zPGx?QUMd;3@uJ7bB=&>xuUK6zJveK-x!WPUg(1Le5j2zBAG7f4f5ozln{!W_Gp_^y ztWMUO?d83d`2NMOAFt1NuC_@A-XtRsh@Oa)G1@-^{8P$>J-=$c)#IRKW#;+C&rQd^ z<$0wlF5Z>#SYXb{FC7wslX7J1AlipFUnG+%) zX|*QeOj~hx+`cM_`E=*&-|0~G1G@naZ#?ip-|nA3)Iy2X=iM+Ij4|Fd0JrXFuh1#S zybV$(1n8WX9DXi+OliNPGFzvr1sbjJQ2h`0 z%rHZkScTT;cq&E<%5_DeuQ(03ek~})0ZOU@m?gNOss0CpAgBam6xkXWMd3(OVFz>J z;=_md#eMVAJ4u&;60@!^jUr=widBv(!Drv4gN`?HBU7dcM`)J|{?@AhrP;!@{-T33 zgr&TN<0((eex@AH$J$bO*J~ak5a$ynch=#3fAy<}>g`gKRXhktF!T|DEvsh~o;It2 zF>QgqkFKfq9C-vL|Qg~NhBCNmn-QX z&DyIq&5e+{Hft1(B2cI%m{Ck52%^$lj)In``u!#U@h{(f^{l76C$1x5Mp0)uL~len z<rOf1sS>TOOp*O<4~U2TT$k$Zw?l($H85D&(0LF9{K!mhvdq!;5)<^ z$!&-UFAze7jzP9BA9eA=ht7u=9+Bi~wQ4Q@cap!Far{8XoMGC$mX2t*s67(uew7OT zHEM{D8B-hsGaq@)EdA1JjkD(MK84MV(}sfOZ0^w=ArdUQ)hfogQuyujj~{i9R;K-h zvtBF%x{=93evtp}W&_WBa-BQq2H9aZ82#>HvU9L70fOh6NEXUh^@OxZ!KIsK&>H>;dKqd>~T_nIQZ2&?< z+21=qq<%IxQj7!-UI`0Z82b--8)Hh}- z%(VK){MEB)!6sl;6Ihjt8etUnW=~(7Kfer{=$~DLmm4!(30$5pgDMnK!2~81+CM}o z%G4f+jD7CvFghh$V=xJo200s%--ewPZLaWC1l)q;#o4sA;Fvh~O~uzc)qlR>t3bIF zzI#3v_!6(JzFhLpW_$UJe~CdL+V@-1L?qG#CgI{9EAlH@0R}9u>@U0h~JF3lgHfYWm%B= ziOeUnk9vUV1uVt(rvMpk573Z;xVy|_a1ou{m^GCJTAhd3%wgNXjurw^$#T)sbf`L% zyaE+EF%=UL>iQzCe*5K%uV3;qsuAA1XuTyPVpE9<=ExDtnMFsH{`nml(g4N}$}k1m z_Ejy6&U8&N=;-l}dsTQW}!w34ly`fK)5JrBT3J2?FE%5P*btNMuS8 z>C^jica7ZQ>OWt#d2K3aE@=t6UadN0Kts0MewdPvT!c#5KS^lXyWkE8ayEz;fo7Bl zq*>zFUu?IVNYnLSzWm;wJlOOUaSobo=13>0h}XgNe<&J?$ZQx~u-&&B=aB~T*MVq0 zgaJbaNNki0&|2fk=*=Bt|Ji?NjFT@#1n9JgJvK$?pby>6dVpOC-RP6$_JCFV9HS&h zSBtRNA^iNFeDu)!aJ61r5Lw%%^h-~9R4x!=dKBEg{^Uy8kR&1iI^W5F`#?lymA-dn((OIMfONC-QySO1CIUY2>opgIM%rBv z*gMh-Obhf1vUZ)%pst{gVF9ly3=d!K4q-g9?kVC{U66YyaV+?o6&6&8yGpKedP% zoi1JSMQN0a`-<1YCD(Pdl?PGn*VuQxxnq{5ykwEIXRI*`_Fqe=ioLku()2W2z826TzvS@pLNcOGHZX07DHr+Q^wlT~a{Oa`A|Nf)TzI$f=lu#8_APEJui4PbhbESV)!~RCa zJAB??SK~b|{ka3VKR>7~Jm0c4hWWS-$aJGU(DrFW+U+J;M-yt$q3ZvT=#V$<2e{B5 zj_}58?3c{9IgASlF@S`TX<nj^8_GUdgC3lk|QPlARex_dsP-QH1d9J8n8$d2H)# z+}wWEW|nds9d+NS-``42V#vxZ_gdP~nNh&v70%%W43m}!m{%`MFUvY!ytfYTb>UQk zSV%Wd1cSwGh$e`c-61vP(h|RqGEzvDTRl~I+;d^%yM;$;K6gaV`;KFJfVm&eHoYX@ zwyd4Nh@4aYEVk?FNWYY)FKqmLritm}9GEuiV12reTe!GzxnRzhF7j5uyPOtL9BqW zs*0xK(`p((8V=+Vi9;QqvplMZZ4i>?Treq1_`s|cq--)(C~Ck#)wi+!*d{)V$tSlY zW#%w}f!)gykvCU-G?`BJXQ0PAvRGcSMrwiP$L`1YSvS98NIsN1b+$pFDNQZYqRhk$ zjOl$rHfTyviAHgo$St?hX<$dL#JIA{NBYBm_(0a-<%yC2d}n~FGSZ2w5Fx-~89!{y zwffKW+L2mdrb0@8omRJza&{((J<~Rz!iUmaz%zxyG8e1qSQa8LvRKd@%^B0x%bLL= zj8-Hu0IJRXYiElow66PZb9N=V>162jdk8F zA)Oo&V>k*6TrTVP%miBBBo$F{0xkb-vjK+8jhf^qN_7TAm1V6= z-Njl$2)*U!F^XMU+=56<6BAGk&Y+~iBw-#DSP?kY?rJcHX)ti62BHS>?kfnUD?o`> zw^^h5qw^eyIrE?LUSYUC(fIjjc@3=AWYkgbUzW183?{eD#(Pa=g8r{7o7w+%>FQ$#bP_953z4d^d}LqgFG9PB{a&js+#sc57H zi_vm<4Wqj5YRhL=_|2cc#`+$#695*az@3c}R1ybhkf7D=0Tyh{Oca2UD<@tX6Bwf~ z!$}Rc(xAq7!N9kwS_*^}DBKBzFlJ~C#dT_I3HoMF?%UGF7%XeSC z2$ar2*M6mh?s#2e#|v^|ACr9IbWHId!&Fr*j5brXB^U41Ok-E_K@~Uo4M+J%-YoI= zNO|jxv##c^88V49QWIuKbDJ~|iezS0Gtz=F0_+4`PpD(pzxepR`{2xbiBzSk0$RpN zP%QYp$Sh5KsSP;je_BKwd;DsyZC-gZ(^a83b1r(J%cKlJ8fFEHz4ejN7|kfey%m1` z$p7NO`hEu&ArVgCojT`SB@_WBh?*mmOpg64Uc-KI$O-fq@6lOm);0Jr6pv2eqeFC- zXY{fX?Ns_l_wdnuISDU2kq`;hnAB_`T8u(y2H;}`yo6;2kk$t&26#XH8gAMH>E zcUZF{=WLfdqV-p>Lq}O`tY6ig1je?3Z^Hq`nFO;Yu%iXJ_y#$Jh=vIJ`i1@dzkDyN zvuy|w-r&thi)In+AoWN$>h_R@*%Smo2mv6>0Is_E9Q3;?|J^uZ98RcrZZc7o836q@ zPXLCi9lJJ#sryCee1>U7`fykMziUlG>Fx-?Ie3?h`57!G&D?m#T*jN^;;X=d&gjqBMS%39>`%j;KA6Ab-R3&_Np=Q4iLLq9Wfbzo>r zTz#fm;R;SQn^UtPHTk20!Ix%804PSig?9gz5hhRXy#62C)SE=docj zT@?~BGDZVM=oOw8#4aDN<3|rWA1_x5kt~ju!s3uj@|dsr1~Sls?av+$pSj7298#>x z?E8NMaq#|?R!tp6nDiB6FDFrT>b#1WMPr~dXoU6r)o>774F7TWVe1mfUT)EvxF6apc=hZqzJM$Si^p7V83* zs8EqY%c4#xZ5d=a<#6uCk>jh+8(dENdZ8hc>%b4(4c;CFt_^EFCktTe0cK*?FBJB~PS!v?fg|FVPq0r0-2 zA-5EuyW^)@)*rLnOj1HgnwbDA?}T)!0Jc5iFc6&y6yD_k&491%Wj&HIv$EXcmcd(w~z|Wthyj9)tVp?k(ttr#t_Yn0w?Gg z=xo#3`LEw!-PfzmdN*(bQi`*KxH3G@N8DA z7)mAzqE}lfR$wPk$xi2y(MYE%yDH#GNBTO30M{zVaa}$V8 zWBFV~VekUy{61&jeeUxV*hQ;I~TwYW`nD(>>XFm+x@ZKHP-?zngP_ z`pLTUE-P;Ja?=xvvbchrn%f@P!JmHigZ=jN=a;sIu2nl#2T9_r zEWOosp4>w6QWF8j1lBSfXk8|xs?!?ZiRL*Y2aFmfpzT0ln)M5?L|sl(mw^T0>2-`g zZg@72cv_`9FVAi2#+vq65red0zyu-@O`=IQ;*L-Yt9bt5Lw)bmR=Dbf4n={Tt^L%g zOJ<Q3BCk~vk%)-FESB=ZqA)6~B;^?u zaCtVO`OeHEfGX%X-kRY^Z_^`tfFm=Nm%^==I=usV%oL%dU=S4*6$$}S5eIe%Cvy40 zDSvS$T{vGUEgGqW3nqPJEVysdxTL0_g2_|cc;g`~`G%3quWJ#gGJ~cEz)}y8O@Rs{ zr_}>grBlsO8Ow6mu>9HD0aZ~}Z4as%7{hCm!E#iZ$o5+F7hRT=STRQP>Wu6~uk`fq z{`~Zt3k2yZOGelS`58+=@~|B`#1rw?v9^kHGg~s(2fFBaJudsn6}Ex zO{m55 z<^~u+od%Sc^g#-R9g4r!utMQz)hbe}Vd+Em)zT4Nm{V=l_`gltkkh^fsAVVPPm2Gt zj#xktvU5%hAe?tsaNlk3{g>Z9yNq3A0qygClvGLc256K0XxtJBfN`D|Ns4GB&aXC- z@C(M%wXY{A&CK!B3;nn|ZeBhCeTW(EhIG!2k}qntP>o;ZjrRa=AqQX|Zlr*Bs)gj- zWTf34x421YDU6fiZ{?keC0W(Bk5&v}%C$0?0B{fXKk>H9AhJV_f`pp*EmkkMdeY0^ z{ps7@J?OfVxb4$&9IR)upDS#ix>XDt%qXCKwChw?>1u}EMgmuBJQ)wBGxlHIva6kc z>y2H5NFfvKzjch6-MIIVLtdrgzZP?S9D#Y@_gDYQA^L&!y_aCpM?`8ArK&M@=vQ|B z@k70*7b_pl`m&Ie!K#Q74!M)tul;?M!W6H@uXr=xn4LZ;%p7ycZj7*0!el#AK%k=P zND~EAOc-fQW~owBw1=+cW)0-h^>gC^$jk=AquPeu=44hXI$;5&Cfq|Da;~goLtasJ z7JHBF1Hb+7f#ULqQ-uH~8X!G2m>RQ_aD69f(?W0-=2(J1UZr)v!4G9`n{6*^tb>jQP_y;%Okps0w50w={kWeq(CKmGmiI%Bi@#k*iyY?`Yx7^}l?e zpS-u0?Q_^xRC^*o1GSF=I*VYm^zKWJNU$Ue-YqEeo0i-G58YyihEB)c(c|m4fkO0{ zyJJWTX*`R|*t7bsN}I680dQ$D4bzh9lKU#)bU;jYynh-i7`Dje8pnJNq;r*!;ut~sqVL(J; z8)8Tr0E=fQz9m4W{xi)1W>?@{(aT+>^bJoy;BkME9K+Uw4iP==FE#G($yH&RhL06|2!F}=M{K=Jk_8h|`9^}#70uliHnb`l&i&p7Rt6cspyI-wJYxEdw4WCgRU2RITu zKBmli6*2KOSQ!)ZTo#MT8pH&lz^{EIB&j8u_N!a1LLXzGMXIP*uOjNSoSm#w1rD$> zms%#gf*|;q=N}o-a8O%3dr%;%NcSMAWp+maoR0?QI`15}=cl^;`I$WQ!PzE;jdM!X zO&^1SZ~`Y7nxO&klifTxa!3)rc`^5Dh2S(m@CYf(5C?`K%N%tTm0{%^kocOHGU5W| zK~-rjM!sq_hjGW98O7s&_|*e^qeZNf`ilD775PI{N<5)LG)VWS@2-CPWxTM?q`TT| zy3U`R-rE+N!u{yj4oQ{GPF9B9l+@yF`jFm5CtP>eP8z;De#-UF4pM_YMoW7Zt@G6y zYUX5ff0EAanE>oyN5vnVgbGg9IR%rD0F*#$zdpnvIQ-Ri(7WSDpBT(u2B#;W1r;H% z*@h5P+6hPD7e^~VcG#8(F>E3)vHI=j-~Q&aZ~yTx-~R7^^ZB{m2fRyBn_`q8V#yjJ z1M^5yzrhPgfi_9L8i3{uSRF-Fj{aHjuu{YqZ)&X!idx5``Hcm#8tf_AP<`6!e*&7$ zXDzif+2L_HHW+CCd0ZmGHr4-py)`S^PLXd36)ip|5cPeqz#JK}Wxf-P+mw>TC@XztK zxqITUml#$pVheT8K{Yu_xg^^ZM{#g!13AQ28s9(VFYifb=bdZ?F~mT1!Q^rqM7uPM zYLPF?an~ak<6X?_kT>X2H|bKhGkXc?OkCQ&Orr6DBh|JUFB`Jevu{T3(Y zWE(cyKG5{YfSTsbCkmvM0gm!E8W9Xv+dc`y6+BtJo}KrudHBg9p&>+LwjSv`QU#Ll z{b>%;yD$N0{}PV5O&MJ3x_YBX7-?+oJZ^7s#lOqZPdmZ7L+4ag011)7s!!TsT6v4C z4CB=C=juAs?)&rp+jF~+d*7Vn&)-~_zb~SKSSn%yROUEd3`GA@@C{HEFUw2dYxp6k}alYv<32ZT2|yeFTzWCqZl>_qX!uM|hs>4F|n(={ajxvS&)( zIG^Nht5Tm6Xx)d{CrXNl12t}ZxOlh<@1OBr7gAWJgSw<<6%kd4YR3B$vZuS%|1Oq) z$iM&EF#p)C{3z3#o4xIG-&p-{he+h?;;(dTN6=`h31ig=Y$M=Q1Tc8_((WrQ5B8dW8o?&{q<1LVBiVZ25URfoQUm#WO0EmdmtbK8Eq!EBA*>%|QJmDDq-Z z!`oS7P0geM|4Y{mGK3xm=Uj+1;QR{T{?LE(tbcX|q)AHgtBhtX995k_XH8IEG0ga& zdF38n)-fgie#n2e`X7B)f{4Y5IF_cMa7*Z&6Oj-JqH}cd;%8^_vr}E8N7$syb0Z?8 zHw!jcU6t-vW>Np)bINOo{ypxlA3o+v_q4oCrU6Zv38qvk<<~b zsI?x--a%ukB+#zFvjUH@jMt~Sd_s*d0)!Gi>d99x;~&0x`Q*}ix)`Gz7X>gp$UTD{ z0dR5EQ#3vE^kjX^YVleuq3({iUk(B2`&9h{g0Axumd}_T^OiOb{ZZbH1XiURo5Xs( zmXbEJ-A0mS;o_YE);k0L*K>bzvL>_a`|@hTg-Z1(;Box&jM&os{$D@u&6%t}|L*)U zb`Te1ZyL>6+QF6T=!(t+HIr*>C^2wGvx#S6`;Q|hi!^QcQiDG=yks=9Y&4NHRSCDt zM3*6sNG1b;J(;!g6T4>ikQ}mMccX?`26!feevCf5Dz0DVx-I2EZG1@2}h`v9^lAv#H--e#|5bENpO?B z0=gw7z?2)NnIj_73A$o9ZvyK?HNgok=}goq)V8PCe)Q<%!Ac}y(CM-}l*Pgjlauc?QLS13k0^6)S(k z+xeSr00>yIHy!huEml(C+WYDi&__l{!)29Xk6P~>y)y{n>iIY;>4%X zj|^RBWGZcO{%PU@WEhtZOre?&FzkFy zs?HQC7@|Vvs(;QSwFh?Y#N4@#j^++9aoW`1i4vGOEZvPVQDdLkwc z3h5-CVDsMEes-olUL`KB7JYRc!K979SPyF5_%X-$pX~&&VBIjSEO&oYe#ds-%F*_` z_s!?)Tym2mx)iELM((L17A*#J9-{c_$%|)Kc(%p2&o4p*00W$tBC=@8yBSp780;B3;MtsX#=|C>2LTJs6U z7%lpf?=R&~-&}t4(ypSwX)^BtYRGtIE~Im6nZGJS1~ke7Z=~d$cPYx?%T1tmMiKC2 zr8>4tlRK@-90s?*SW66DM6HoCm`q}7%=lO|15Ww@jAgLlW_-}a zHUh=`!68TsyCJ5Ta@D+yd?kpb(%s5NXnCSnS4wHXWkiVq!APf$Cfhz<1xC0KRdsDK zdTt+f7r%aAPWiHnSDq9o6($y8;zYerU?LFb#4E%xD5F4C{C*dp-F_g6LlW>%KdB#@ zdyWQvlPPmzY~`wxoZzW;e8F60KW%+E?oHHG&4FrA6WUZ-p#WJp109C2OX7%CrpVc6)|;^^Oo? zRS|Kbh^A&DMu`*xqEJ;A0dWp0F$P)?p{Rq3@#4f?{PiRE%T?&Yg+nwNM2)@=x($es zpolQDm~v0p_5jQ^Uo^%h^<#n@?@vZ=%kh?de|#y?W}?m44dvqA>8#iQpDa%N9NDR5 z^UdmokR19!nzT`j>59t&mf@M&l=%4x96oep?IbueMy zbsC}%`tbK(U;XCGmskF51KDy*^e|c=VpIZj5FcpfNX^-M0x(h~MGV6k9L1m}B4%PS z#1IANTW+F*crknL%y%?_O*Yfnx_@F6ySpYF8QyYtylVBIvsF-?7g8r^h&j)bB7_<2 z|Igl^bvKe^X@cN)uZRNRVrFi>MMN&ubE^By|3C9S4?Qo_wM=DIbya3%WMo7}gfHQX ztt3G}Rpfs2AR?-wij|9*)5C$o<4z+1kkm!w-tT^kmp3=7Xy!nP;quIzf%(eJLvRW$ zv)-UEMf|xp0A&JDp%>{NPE|?)G4pBGY5>+DS|x?4wnuIN4X2Cy33UY{0D6}L9oPtG z3h&g2j?zYkIB;D!#K!eYTYZU2Y6}EVNQ_ZcL+D8BLI`wU|M2z}v!(g(->q6U(VV0N zPfcFW2}ZIZfvcYsmrUQX{nQpfgY!=hT*-hf^=Sa?y{Hv+nKndsmBdO+l)>e9C|-3s z_U;_xc{08yWFiOH$o#s~mcLP7a<>EE_ET&e;e-IBsv%Me6-!r@{|Nwy#%N6E)+p*` z?Rs_I&~@+s?u&~i-e_FUy!7dwgNb)OYq=&-5tWp`Dg%q^czG^yhv!SpW3H!^2Uo?d zACIuyYAQe=d8Q_Xd(Z>=fPe&|xFEBDc|=q6S^e0#3UElk=p(PThmXJm!;-fzLEQfS zd-Ah=zI-jO23l++hZ`6r>{mr});NjlGg<%odGpDFF5WstG$bySQGzj&Rs}@Wj>jYi zOJqzxDORC}SLWJ*{w@=PJD$4HZ^Lc6TaNq{M$@s$%9iJ!HJqQj@nIM%eGy;HiTWq^ z08JXYj-uIXQ=qa|3r3xZC{&mVFPk+Y3aIO>dEYf(zwN$$vs%%M_nMSP>I9mK?8rzd zn8^>pu!M-tkiv!=uU@E(f;&zFhOi*YAyDpmRdR zO&X|U?#O~>PbE5KBxCgE<;m0ae!nvEAzt!#effsC zP4<@7>2nT_C=e3_#$eHmFe-)Q%)_tW{Ou>rC+9OKg_(2+(cj7E-doD=q>}G_{+;v# z8(8U<2JDvr;5~|_YrlKaJyhMh_+WJ=KtjZ)Z1VC*y?~P$On~^}(tmbl3%k}Bz(jA1 zF@_KVjRK)aBskKISf4XiSIm##1yn|b_c*o(Mc%q)n1+LQ&CbpX6=(BCnTRy9T(^k?v{eBm zP!bf22+2uLs6t07At46meWbuOuih`O*X%fdchg?4Iup&IFEANgpX)uQV(iq86ZJ=C z_DN+|S0)3M7H!y&Fxs*5wLaSFMxJ*)?ls+Xw-=+xa8m6ZR56^Iy*E%$e|TU(9)@8rC> zI}dm?a5%Cyy~i)`WXt+vV$=@)A-j&%F3y z=)$T=6c>Y;#Td=fs8g-Fk1EVQmkx5Y(DR@(FLtYgCTfh=mK@B!DPquW_ zLuA`XrNde?fNDz6zgNG&j+MUk(ppBly#-c91gfK5(%YvVKqPf!X%{j`R0nl%jT7&p zHm|P3zy9U=o7XM$+_@Q?GgT&Om_#suCPpzTkruf$I_r^7j^dESM3?K<5_1W*m^JdS zl)Zg%1gFQxD*F3ES43cyJC?$TE+t8mB5eHB61O7C@S~NuA1w!e}-(C(Qk03ciGPLyXN*FuzT@L`X+7f864t%_n4E6 zIm(sPY2@xIWXhw>Nl6hR#LT)`d-dmMvYx2E~-Lh*MMUPl1W z_w@Du>)#)b9+K?G^7oI_4mJ$CE`a?3&pjJHT@RacfF4l>#H;sC5SK5S_SY};(seGb zCGWNwTaE@}_Ez1M18^`7?D&OO?$EH+W%Ryi3>iNZKb zq>iY#Sgor)*B|{vC9#Q8v~XGNv-UuSm=^+;bYBVn7Xehw&u=;V_C0_9-R-h-kuE}_ z(UM^ zsN6qY?oVj*KAx-4`{7@n&%^>40bJj%OeL?MoU*8sAhp~U89}B%MHo%RwZU5W@7^un z--ak>KfYVNyNT4fLB%N!%ZWB3{F+GH*eh``rIq<#qQ|egAn!h)&djk|DM(VE~))J z#uQI*t+)rB>8>M@U0kd?jT*Lc^Cni*dn{#3S*d2$s5XcVSD&8YvvZr-jZ2g_f|N*x zXwd-ZF^0K)rYuV-c^_z!2aT8Z-3x~WMJxf#8+-Id0OD}B49j5otj0onZ@{7in0hpi zGZs1K{|thzeq5?K0!#+`APh1CWS=LBfM%qbg=j``(Ve|-XMg^2{mrY@GB|@2yuwnl zj3l+A5rcd&t2qWgm>h&EDTMoKc`KpsI=A2^IxMXz@4XZYbpUDy)UbOJFPuJ=3 za~mV%5?+93=kQ_)N55XKBa!k@*l&3lX0!m5*_a2Q2@R1JQUPqzyj|H3SL>De+n}%B zwH;%X zig9(lrB%xahL;TA;*N5A>M_5yEZ*3;53DN_Q_X@h8QH8 z63#>-sOVn$_20knuDh9gsG5kWDmlccZAD?Ro3Mf7;-WT>-ky@NUhX%YYK{!FIz0|_ zS@#M{-qYzF*;Z|O1of93_GJj9kro=LT3BfN#d&-=YaP3sY7GsBHpZAyz74xr5@sWO zw-yB4W!0@%$j6itJJWpkzzTye!bXUSZc#u{Wi#t=aQsXVzLs?ql|{0U>8mD$PsT&#X%b9XU;jH3XO>HH?jHVZTF`i*EeD2T;qi5N|vx_ zp{kB*xvcvwe#d1LAcRK2&239UNQZc_*uK1OCPN>OwVl?J558V;{E3X*duwK04|1hD z%sXiulHrqxvRVjOy^s9-H2_r-Wx1V8f24^fmF~&Lr5jL;8e(4W$U_DHsk40=H^M=m zzW!82Gmz&C%p4SE#BCS5Wb{9Ao*&BSQW%qh!my+@-gm*o&wqTsdV6cj=-=JOtCfjA zk5q_?OPh`LK6H8JD+03epQ(6@AV6A_o?dkF5<4;n!pZ4zux&R&XoIM@FE?QCQ z0kA`~n$`{PR0uEXoqCC%p2g43V`I0D0T99&B#^f{w0#KgXq zM~cf~hl{zV1ywb;x7QX7q7F2YDr!b1o|xt0IZb3^jA^qpqY(yNE!Thk%d1uvoh-ik z?oGrjnu>2s5MxLVeX7#CMCavRd-i-ms$|SL{wS= z9gHbPcVOujMugf*&8DL=g~c_DeYaAzEmanmvD6p+!XlLh7!y^%F7okKX3OM6_p?rv z`DG4W1GCgegtzPxvZaDvRkij~7;)^lz&nO9eHLq^EWl+e5~&RrI5dOuPFqugvz*${;5f< zY0V5cq0mxGdVmg8A1PZ-fxF7arUNOqu2zw9f5 zfxS4A57fI(68q*mz0^8g7rb{NbTm8jvap-K{iK;;4FvAp9X-kzXICciJ&yJ+#reb%w1)i(OAvqx>DV>A{q+ld=GQYvM)SUD z+s=7^yIMuUm_shJ{YTx1);B+QmU?#vjJMtI$E+%wfx=r$(jEb?J}}7%?mK~c4lv{d z3F~L}N$S-xs?ymZV_d&q>IZ0loy?^1*fdI z#pM9;qa2;nW7mja7xsT}g%S~3^n%OtxiA7^;@&S;5Ja=I?ine)ArD1z&^iHAPz^#g z5vm4@v$OLq%Bs_EU*D|Iz_|q2sX&EJ*oT1j@J|JxhtkVEL*A=0DD%|q|QNtE&) zrNRC_Gp{NYX z;{mwQ;h9D2HO>&Jz@TbMz>(;ya4adL!n~M@QDhnYU*31`JHKXQ>bej}ajr>`=f=V4 zIQgEbNMfw^gS|8ed3U`E6j4VD&o35gpvCRr%yCSfO~0~7Cq|wg$MpbJ{VpIZ4V!M) zU6Qbw#k0jM&8ZUKAvFRrYWDwHCIABvD5Jlwbi-Mww3s)NW}xDhx9j@(rTL$r7Docl zr^g4}&U=2y9{R*&mYpyU2Ru8QYhsB&ua+zJWcgAyj*w%}1Z4WZ&b_8nfQaF9q9LzgS&Vm$CQ%Cu`DogVm-#V{b!2(-|v^d zAF=9=E7jp?&#rcHkNyAjiz*_@KI)qgkw!4b?H8ALHVZB;5!T*m3?}MBOaa&h?kHn4 z>&<_9_ZaMS+I`?5+PWYd!x-5*G}v_)NSV0SNi3rtpm#w)?=8@~#%{YuwASBq!%+i*>YP%F1 z0rLFJkGh1u10PlY@>k&kw4>Ag+#-OSuHKIb?LXFgV#?W7 zkYZdk(l~E~iLTnvb{Uvv;)oALWM-~ZJQGWr;TVH+-r!z*x_I^W*8Bo~28AF3QN@I& zIeEK|{zY-+XAi`W&>)q;t}gR)2Mk_D6dlyjVEsx^<_397myx&KXtbRpBYT?ccxT>K z;69uh(a!7&ur%Y)*j&1+Yy100M)JTUn*0*L`W_T$H9X z!eSZ3#117i4HQyT%rN8izG>8sbTTdZsrO)Z*=BuZoHbyzG<+@#b=ZA}-@cF~e! ztaoQ~RT_2|>qnmS`vE$q$78esTk74^0Ho_68rsev4iFX0XTx*)myBG1J{G+Fu_gf6 zy2B{|&;)22IHzgG-^CbgwD#4m>pVKr==6B>QhwJL4>k|Q;j?I77#oMhVoov=fmq(g z{(VFw<~X>7g;|aOwzSF`U|l=pGuGIdWdPV@1S(YxJ>Rne`>p6Jz`frSm^A8{f-?j$T7Ei zZrIrO0L+fwiT@KBqcK}S-~etu?OUT|hBuSq+h`JJT1Cy%R(UER?!1e;^pfL&Jc z2U#T(QuxI8@E5g1Kg1689?=1NxL(KDiSCL9+Y|jevcFVib<#&vLyHZ%PtWYjXChn) zA|Nz1N{kkw#grY}BOrMiFpkl9z;!aX(aQns|E|NFT6EOo@%=YOXN0W9NL!oa!NGG- zUDZ0kkt|TAX#f~Gvz1PuZxyN(u)_Sz>0qS?NYi62EJ<~!dH^RHLvZu+A8+Nq{_E>b znqcC@IhE9&fdezwpY%d1z{H-sdnPYT@>m4uNIQA{K{p#zmdLjZ#pgwEc)4gdE){m!(B z5M#=5j9L;S6iE>ail*(iHUr-z2!;n*L#L;H) z>Q^t^`JC#=2&c0|{SVR1G8d@tlLrsU>i0hq9zN8e6Nh;JM|N&o)YXHTA-67Q+XK0G zp8C9^>UP&o>DTR?@jiexs74B-On_m*7l?>cO<<5q45F0IoHGSf)B#D`D&OVC@r@ZQ7B9EnZ@ORkV~p1~H>t-H(aX!r6o-x-3CfSYYoGdr z#|8g)#ch&V0ANHHQ0{Itjd!W1B!wft>_I;NvFI)zyaAw)Fexe7Y5B3<`Gler2UUSr zsu(~cyO7;NL}e53CpKHI)8nJ<+R|w$DkxcZ`NIa8k7mJDeAYh zG-fGqS}BVUW*22GXaDfquWx0sG;L-JkScJ6POAO>F+{_ZJ-Yvedd5ykukGof$rG;1 zlB5jyQ=)oiMz>U66Ub(5m48LG0aTOg4Xgl_Gk2AsvA^Lx(qD2vn7`K-XbXL>54ACt zwC-|WQ&LJMKwf%j0Gv%fS?32UMk-+dh45eAIobJCRZ9_uK^;1?pl7!{|EGWZacPTnS7x1z zput#KBlLrp?1)Zvo#|~GNH~EL zPP~7A9TRv$8bx}@B5!GV`Ag~Bryk&g1D4x>UYQPZ_rPog4qUfI?k|$iu%|Em)t+0>(KSa%I0D~%NRVgTSqp}zP{D}{Kt1!{*sH!7}-G7toU&E zQS!_I@1$gCxeVS?GC_jY#$%SOg*_W#17=p?NlwU-d%{}wi%{n0FKU9nojWmcw5qMJ9 zZCHgEAW>Y17UT7qzx`s?{>>SlIguDdAYKh5;Y>W$Q$-{yqxLq52U31}qsIDZ?1BG> zJb^NBIp)Kr2N|0I3%g3m{mQ)iZPf%#%`^fgFwL%n0-#&3y{Z$jNKu3qFJ>|i?{NG5 zuRc3ltpS(R7R7HdkI*Q9rq%nAO33|xc*^*zm>!%Q)k%ji#OWrUMG1qgPt(Dv%O>)= zD>Y(Qft9lY1W}?n!l{UQah~d+PMjIN^Ahb``Rg zeodz%nU-^|P%4>v**wdsR{s57E&0e~7D>rH)&ZQ-7F;hV&N(0)? z3M1N3gBNG5Dpx?tK|E@Sh5{m-v&{j=Z}flx@}oMy14K%P3KhmJjbpo3Y2=-~ZdB)_Ai z$>EO~rOXT)U>+k3iNC!&P#JT}kS~_qCu&Bl?C|)%M_qV~RAB!zIV$~Y%7Kl+VX3nQ zie>?q2BHHi=z4NT@25K%6Cl-1kcyqI5T2+OOp*(~|M_NPsO5&|%-{db{~2VmOc z4&5Hl(7mnlW^B@fAieuc0rJd6`XCJg=a=WJ)vAk~Ya~WoEb#CD_RGKdmRuP6^0i864-U^gm3i(=s@ujZPWs@Zf6P+9$PBtEIb7XpsSbNi6|~)cLgRV3Zh> zZRMO&AgY1>1{eRo-+pKFv)d@%&tNo*>O>&jy6d_w@fXy&5CcJqbd=kGjw&JwJM)-n z`OR$4U3MyUo+fUJF$PURnFsXaDiZ@%U8FcDRN`W$4uGMb^hFD-UGiD#@()i0(vPwm zZD0MJ48?>9&d=tcC6wiM)mHcUP+p9V2u~?sKS}lfkax(AkRn3ke1`cf*}pOZ@2`TY z>uIoNk%rI)=bU;C7SceCf~d_37hk^#zyIb3_?b|g!Ku(fZ|bj7`#NLDfRV9Nwf3~o zQ8;`LGnJr#Eqhqa`yYlKd0;&a%9;8GVw-I2Mp8mA7?q6K{ZQvZ*?|MK7e`m--DXLEsA z3SXh+mxxKq-^g$Bz#8OnkJVk1eed-3UEyE*^Ci-Mop^^04qfL(*$Rt_X45Vx47gpb zGA0O|pDiZW?UwucWBmmm!v#oottMsHBB6q_S z2d3QhG9&JjeK|1zt%U)oJftVuvfgAG^`8Ki@<^ zccY>A__*^`u}#jgW7Goy9H1si62?TR-2jgz6)I!!mLpt zJ@9OS=M5Hs1|-{(Or9yb8$~P=DL!6rbKj@!jkDgJ>Toax;^15LUsO@WAXQH*iwP42 zR=t4H!5YQ|!WS3*lLp$ZoERlU2?7nRt=pi^QAlQ?JPkm-752B@V>j25)< zmJ)z8OMcm(rbOu7%m3M08Qp>jB*-!`P;M48|O&(T4 zCnhAQB8^JJZh`ibS^V|$=3F|@&JZkh$tJou2+Z!{;NbfN)D^j*vozb4%JcX6ZGiCRQtL@)@VLG! zQSOo>3^iG(`6j4x@%$gZy7}$5h|Q&`H%qRD4571NslSLZstQR$jq?8b)t8v0vL~@qCMoei z4f&S@-{j5FyK`0ogFLSg9pIwea#Y)4f3fIlI#6SMq4{`RK?o8dx0-b2u6^4o*gVCE-9ME3ot7A2AZ49SXhJ39GC|yM5_S4gZ z%G=5);c{D+(2zvdLt@9)9}`jCo?Nj0)a-K0xqC6R+aW5YSZ1<#@x6lxjf?>r;9HZ< zvas7oC-FrSetn?}yFGWbuo9(fe2gY=DbcM@->hZaJHgl8T>m38ytddBJ{bG|$UOjy zVwVz4A|wJ)Nk=3*8l}cS@6IR-d=5zuVRqDDy@ROiqC@Z<6ye*gNL_aQQQdO}p4f{4kIE`_@p zb$Q!$h6spYF>lUii6Wj;gpc2mcb(80+Kig5kD>iIP;{axwRk z`E-Op)Y9c#wGph&>W5x&KfDNd5PYzc*|Lql|LTXYU$6i8 zru~2ar{AtP59X*oQUqwc4x9#Apyq#r%A{>l*A z-HQQwbBVGRbE;iS)qhETkq4DN4=t*WuC7pr0;IERoZwY?N{~*o<}l4jVWhTn9W_8q z1Oi2vVf{%HK08ChHLX)85HSkGDYAXanC{Qyus-Pljse{dMk5cw4?p|GB$tTHMv<9q znevty^XSzq|M5mfv&a}N8VtQ1Ryut{>Hql0<*p6YW8MzD!`tq$#oXF#WXu_?s((#H zVDEj1=A3VunF?l&cv*ik4=?9-hE8G!MMri4;uZleLXi70BKbmYM+;<}1|Y{Y?T^%b z-S2HtkM&UIh9huf5(FkgJd2KUwa_o`J^547t9T36)6W5_u`otMSM=Y~VK_`3R9@vd zAk0J*CXKqLnXMvMy7({u`u%Uey?M8c5HI3lv=BRi0Z?^jBp4~nHe}mKm3gC$M{*hJ zC*B7k{B4_WPwhNaL_fib+uwlW30msLVZ^Ef#Sx;w*{t!3QBLrrxEkgFAA{ijVH<#w zejyW(3KX-bDpn2h81Ve!Tr8?cG`zoA7r0duceScVK2}V^X+*GtDZZ;WIEX;R7@c!@ zw9P2wXD^>Kw4{08{P_00sY0|3U3z}kcFk;Y)5agKR&SSF$;Efw{HrU2&V@o%169;Q z^i4y^Fa%H;`@!}5K>_&76QsCV_TO?UQ|uq28$;pV9uBlhrq;{X*z^3-b0*loj{WS0 zvG}xSNO=W!-i`0%`n=2M2N}x9$zS!&olGMq0l*3ONERj(Gguq2YS~4IZz3dwc82zo znf>NP)5L41CQjhgR3T2psW>m1Bb@V`CJl|qQF72>I?$eewSPwPjZMVj2Ue@QexD{=(=t;LyT$5K@wY&?7D6?YwELaW_^Cb2kJk!8ei-`_yQGbq*Shx zv1=mTCB4lkNwg;F~W zm16E$S$Rs15kT*!$;lj`>H$OxOGm0bqWFZCV0iY?U?{{;h(bvZL4*ZUkKo*2mi|v~ z@R#>($40avp(-&3GfS8kMB)7RuisLginO;kfAgzPVn2bna?YoNE^(UNgyJXzes&0r0XZV${)5Ft9Q{X6(1yp zTzH(%ywHri4Sc`sBH`30$T?D@&c1tf^XfXZ{_>l*>(?t;aRzllI?w?WV`i6R;=UWa zoZ&RjoQDsU$;zI;jIFA^I(>#!VnCHJP-WTl>>wRFhoM^SBgQ6sn+(0g&J?hZ*4k~w zpGpGor`lq%8JoKsbSD%6QO z&9WnkOCJzb5hr%0;pL+H)g^sg&OnBk#%sYs7tE~may2lVl=M69=!Y0b$_J)<={8V* z=v*frTm_Fuss8&E#j%^`2yl@>$~DalQPs-Yf21$cYGE%gnMB8kQ%!J=hxr^PAP%M? zE#2GJ{qc4D^P8?i<9rhhJ%^5jQLk2UxsD{1_H5D29W?C_nK7z+aQa;{oJl%0$x~tU zQE^f!@t-{gIADDw5Q*igB?L4jFBYoEo}q~wAfljbYLkTW5h$r2r2!a(zN7oStYx`k zP+&2GcLEwj*CBMF3LmKsE>9uAKZnV{E&!@PRlk=>5~@xlEvD88o-G>0U}hch=6VTD zp2{?lPTXyfKmFxRa2HqR{`~ri?mWU#HH~7GCYq9^a`35EoxtGA-*4EZj-Q4~R*1e< zx;?EFz%q=mhyAADQmhO^F#t#yvVNt4yZU$N`--ig^-tKgZ6gIur_W`-sjCwqB#GLB zNr*}HL5tl?O}pz$*ZumrpT!%8Kv=>?S*9kMI1iZ=TqXd$AD~clJ0TlK$b5H!EB6oH z`Z%2RyTdIxQLe0(Cg?0A`-vDsI+T&1_%50{1vPf&#bq-^l_-dkTDzyX(B6e=)#J^h zJ$54hI8YYv5SMT3JnJ6Si8vIQAi-j8t1N2_qcut&ufKezFK3*^l}8jBz-a6OgTW|u zg)q9;(&x-Py{+2Nr$F%7w!X1mPNnh%$|@=JLdqd6STl^$1A!&Cdh8;KLC{s0ep73> zUW8DzMN-)}7b9jZDzfYXmm-}ZN>fC@kZg*ffwboA>v!>wZ@TxLbW~BFTI}>kQ2d*# zWq_)xFg{x}J~1H3Y|i_DEjTfMs_&kpz#koY8CAADc@2HxI4|jeS%{GQx6##k@&Y>e0CF2GMZV0FDpR>AL>OtNerD@uZH-Nl(;l z6A$weUp_li1_4an+v_F7nK8x~iKxw=zkhqvdGpV{eY?ErNJ`@xibBK#9uzU?;DSHd z1_n-Nx!6jT*{|$U0q}i9d}SO_aff>2&fW~Pf44!jnr$k^xboQZXRspOsQ#bADc<8Y zy;}s*3!5cEI+HF+phS>}j&Xr-9gAT`Pp|;1|LJ3!edi?9yg$egXvo3)t(3wPACkaQx)%AG*BHH=t6^_?My|TC?cJ6 zRy>`M4Hog}_G=$2nTUIe`1&_F9z-8%$0~@r`fje(nNf(63M@!{Zdx9+^s^A9#RlEE ztpEPSe9>LaWG(E3ph+}zhL#A>>_}e^<&3@GegEIe4|Gpp-^Zx<_dDd)hMV=eU_U8cMJ9H7?+0~k3$5BMwv^)k*L|4EB*o(!<|j?0UAt?$wdEm4J{$<475`o_C; z2tVHPt9SOz`}R5rU6Zn4(;rIc>#G}6CxBXfem-vmL}ayJB`Vi3d2J`-|EE(cp(Nzx zWwOe!6rgPpA|Wc^qG_BCEo)gXw)YYr$MN#x*nncfa_-67eLkL+rvKu6Q4Xjqmmyu% z#_BCAM!IoKzc=MGGs@z<+%-G*pP!}IsI?e}l4gPz@V z`u*Ew)Fv98stnsK1tvljj_vAD8$R^mFO_>H`&mJ~wMdbRVH6HfNx?99-S<$IP76@h zaNzD@k-O4}up`Ch$j9Su4;y=u&_)8X!HFC&B2qh(XhKT*kB7N-`RjA}Y#tj~1D&b^ zd!%lUGZ4Rhk8*>Wnn~sq=fO%(b|O_& zMWX>Krg*o++cjE+%++$%6d6qm0YgYFK0+T2%YR6Tx$VTUhMBQD8f>d-d~0vH`^Lkxpd zMHd_$8ubtWX#e`NzgwQi?Az5Mg5<x^Cmu?b_6V^uiYxXI@!iBNgfG zcED%H@5mdqspRBx8IWS{SNx|BcS^}MZu+%Hku%2V8`p6sn-NPwoaxzqFcTtvFa zV7R`yO)Ikz-+uq&+H_6-AXdw`U!HS4P%7+bhfZ z&aSPdby|8>LO^aMsqd*dj-fP6*`JSX%f4%~8JGCIDUfVC^+s3(0=0``0iZ;Mi8uUMC1wjYBJ<;-Nkrosi((Oxc>NCl_U)^G`TD!>t~xg84AJ17XNV4>q>|~i z=}mqXOYmL0zTp#23Fuo<|NSRd`>{a3X7oM)lFV_TE;k7-;S8eUJRo!MoXg)l_vf=9 z(uo0dgtlfatuv!#S>e$`JWM7Ak20~jYv|q)J;1(0y>V-^wpX5twNT?YgVDmaZY`hn zW3ZI8ktF2EK?F!T?NmWg9HC-pT*Ib$wOs${$D8lot=F9;&Qf9lU0q!VA_4%<7qhdO ziUl)6Q3=!dkY@OSN`TV<;ZY&)9!kJ+c?;20$6lPDRfv1pCluZR^LK=Z`~yD1gFf$;vJJ@iW50<@s#p z6XDBW-K>MTwfSX~uV24kyV+W1-@myvYg8H$kWrF4k;UCPo9>lx_`hZr3UUK5p`b=Mv_B;QRwNZb1L9UnV8@;y%| zlZ)xOoCI zjw&DWQLSdXT8rUF^v+^K1c_|c4;k#Qb3w+&YrV_>TD^RSVt8EclamzrN}vpgiKI$B zB|ISQL|!#?F_@yc`1F&`0g%q&ljn1_wFeO8yC2?JbG~+$zx&J8jho-- z?5o!|S8I?9m{)b`00hz4D7~hnMs@0(x-1&yDEUh=*Q62$_k3yT$tPQ4BMY3zDiGde z$X80Sw$`VAt8DIADds(e0C1E$18xHp_mmf-N|%RCS+)@X^2lva;3c7_anc+})~*{<{EW*$5!}YqGz3lwUt6T97VxX`yD_r$V&m>?{P?!%h zn?4dq9-rS)eJMlTYjt;BuHoV2r`Qf=O_}tISFWF`6|6YSY?qkVm)sbLmDIb6K-JWl zx+FttoHTjZB_@vIlp;hRBGo%Ti{0&+tbR3X|K>bOyjEwCjG|HGw(CNqz?Hkta=%S8 z5~6|33k~}YeK=o}YEmev@T@QIOnKSDYI}sTa`Vop7D)=I+Lz|00>C6+di~&TY}A&{ zS9)!4>pT(`bLB`d5E*6 z&HwPj@_&7MwVJ(H#t82s=5OD(7Na6KtUr5kE+H^Nwdu^L5$GK0Ez|HaJ;eGzzpRt& zuj3Lu|&Yb0zAeB2HV8^$2JZ#FG|p)jk*pgpt9eCQ!Q0Mk5lpg zsMY_j(amv~(7qqX5otcZKni9L#!p^cSXhY}O;%Cft?|Cackgdox45N$y9^z@Z_ZLs zZ>r`J^rB_vi=y0>k_1_^|53tdfDK)535+bDeq|50vHw*KgY<&no_AD==*kfYsFQ<_ zfHoiT;~=UxeG)g%yKKJ@N{L*mh%|wR7_n}g#n;* zDnm+xP}2K25tq0Un)f|RJHPH00Y4Zl-hP56r~aN(myd{yXNP%8zUKO%qrn^~hB$Sj8TPew{qEf=k zp{mY{7kk#kFE8DTnQ6EMEqQ7Y+J)GKSWZWODz)eNOzvXd;WFRz*Yd-xOZ)HQzOtWm z4fStWI|8xN6W6SsfewVC1}^e|YKkkPJm7l2D3XJocLZo!i&RC0P@$8c5}gwx1>VrC zqJMqMuitjxzYk~@fpXp1)v5#B%sGVh^NYC@>{vu|@yFe|Pfm@h3ak1)Yuf4Yh=a(? zMd=Io@@41Txw z*%`d3OBtob?sJ2zx$Ubx`@cqTllOlQqHMB72Sd8Sr2nw)Rz~zw>Nb^IO+b(>abO?y zoE|=RW1i1Q_?ylg(|jN&0K^U!NHLQbNifW~{QOd$&s1qK7^se-W)On|6up~@mT>0U zVr3VBeitG85lhdX#GUCE`#ACl@4sUACqwU2j2wK2&3|sD3vbSU_N!x4f$GFNs4zG+ zrub~J%ej7e?$6?_SV$Zbg?2Hv$vFWkqGXvlBx^O)3An`z2zN$y@2z_|`u(?W*%QJ~ zJw&Z2J-I;MVDF6nS=xgQMekUx(VMwdnE%ul^5f z1B^;lDif8mpLEfzZ9jkciAx2gZ+>`x{g>Bo=@+-H{^f_Oh(=t~h3LI6h+1N8s0@yB zeTgrF?`L7^SD}xXhDr6#p4$fBBO1V#&2p4!Uq4MNvD)6kf*T%L$9_EeRk5M>Wf@>h z)r8q85;T&<`#%JB>!|QX2J2K~qwR}%_~O!ec53NG;wc`fu;HHi{-CMIo=#wYeX~pO z{g6)JpylY1(Z#18;7-p!@(dpNKKoD5R>Q76rT?xAwKAbsg%gU3K@2KJlEJh6{0yJY zY>rl9*Hf}Ov(6ZEy7R<0#$Z+`e)a7veNxA+WqGheE&ZD z`G@7J_aW-yhwJO03Mhkp`RPl>HkqXf3^YKEY9c0wx7|IVV0c&t96q{N>|eu@RSHp) z9-f8}W+o2T%Ls9zj%Jr<3we5kb$x0F)DahpIbcQ_ba}hZ%+0*82{T8dbOoo&_3^6z zBNvprt`W8JOmebl;TD5r6pM}E+1bKkCyLg16WNMing8)^d$SUgnYdI9bn6&4Y%q{$#VSF#eteUitl3G4(R+k>hs&#%R{P z5Qq<0lA)SR=0~Fc_f~|Gm_J>#E}9ucTqKAHTb{f1m(TFLX=mtWDl^wK;ysWuel6=G z?_iR%wV1w#Wb=Nd{QWA|dRskJ{~xDYIQZSoe(adZ(w?vH>0mb-+95;0;6xLS4}oi3 zZy3y^#%)I>qoM1S@^%)Wlpef7i5ip}erRS;?X%#oLixd&775t+ib9pRJMgHHgV zKlK22D4+=`Vic1kEfPRf)eOs38wgTVWj=HI1b_dh)=qig@@%e3L!`*fa%F^wD{=wh z5VBCdaC&^0>iZV0TH9E+I`r%_xeNDcOqsqx0r)j&*=mt7C3gGBjw2AqXCY}aFG8mIiF-Cx@z#O+P z=Iv+a@V4}bD)4Epm-fyi*xmu79-Iu|F0o&`K-s$%<&T>Jc6!_!t>2JnA3o#v9t-TL z^|wwT_Dr!hxhPIma|_KJDl7C78?0ZR@zZ&{khNMcv+M{ItqT?mgccp|XmZ$7^f-J? zhQw}EnvgtP=RrJSXRC}NN2rw3BbDlS5!>hiutytE(|a(?Y0>*E_L%?^#6m_Kp-Ksc zArnA{NXKd;AjVUIVH4^1nw$eL_L~+FnB{aZ0YvHMYb<`av9I5>fBtc~ib7FZcQ*5L zr;Q-FN2Gzb7=&u1fd=kE5RME7IR2TZivLmI|2`O~_gg4&pm=|EL#UCA@x`+lP7{Db z2mzJ2T%@dYnqawZyHJ%|NlcX!*OJQW^!QMS+Ggz6y+dS%B;bU{r_e)4L>}u%?B))i zesT_UDiWgJ2LIz#xLFHzLDjUws1)HZpr8}&y|+t1BPP|qXkE(J0RIMsR4dMCFH-MQ zCv}uAw2A%aJp(*sPst4!?4BiN_N}>3H;4(~x04~j3^XPd5Xm0Cz%qcNXX zI!}8s4_{o`nYNytiqRsXX?iE6h^V?fwb3rre=A_((2;3%81!J+0pECFQ z-_F=ug6^Q+A5lc^gY$R5?MM2U9kU+R-9lPvi?N$zw55s!&IexykF{zqC_;iEoNMGw zRBa8#|4l0sAsw6^bcmD@6>;J;JslJaDh41zgPUIk|JB>>58qu;7YZ=ke)4R#a1e`$ z;$USOE@sJ6=Fu05)3WoydjJ-fsER3Ugb_EZwWxQhD!4p@+QjYi)BtR(DQcX1s6-Kp zY8%)^1gnrz9>Ob5k3*}#PlZ_$eE^cz#(OB7K<)7tF&H@8vdox%Br zV#ZDti8@pze-%sZ0qGy-J8rr?6mq8y2otLoU@#9I(0Zmo%B(cdNf^zBu6HaCVBdc1 zXZ!HqEZ$d+4KACDSx*V#MybtscW3` z3ULsxP9!?4U!L*hEX=WPBnm)iq=d*2X^G6JBNrf6zup8(u5Du;avfF?A$Q+`*@N*T zOX)*?LG^H;|4QY|Q;dBQQiC)~>HYp|Q~%--*Dw16;b-FdFBs$pqdCB&Rgjdf?L?ix zA$kEANx;oonScGhd$V?wdF(67!Ud2$pWL{q~j&o3a+0BvS(uL4{yH3U;nuu~gwB$iW` zQ=1~N%}9~`Vx#@*#V&nPvfri{8EG*gR7No(hR(w-=2$o~L}2#Sw{LGdPo1grh6phg zBU=?4{JW)M=VP#WMuvZ~Qfpfv0Beir#3Pz|)b`ka7M}N%(2=?~-$<&piB0CMmfvs_ z?%G7d%KYGNGOXkqG29p1UOuTjrqnE(+(v@Up>vWpW&9t6M5Cw+(Fp0fZnbJfodJO+ z5?#3Y>`cBq_j6k-%*>)$g3E|x)$W`)f$NXk#P(+wcDPmdSu>uu3LN)L5;lLtt^>H| z=N^fkp2$KEugJF%3lCt=2XW}TDv2kFOM6VKCfARa$KYwBqE1B}QxJwY5hWQdHXhEF zUp&)K7qPLWgbu(jv$X?CHRT? zQlGNv%Q=2e>WI?Q&rM_qyd#Gts)Radj%X}$-7)V!+4v|1;^-v)CHsm30&%M4{j=>E z#E6XL3RIyk(;3Bq%HRdw0b=I;>$SdK>8i!t&m`P_{>hnGOzz15gqqaGfC)`l4$w9U z$7T;OD6%$;af=jO3anE>Oh*Kgoa_pYE?J92y)w*8)RgPx+LCLz#f!yE2{AiO01iC7 zbB)8rqEQ5Z(QtL!c9s_$*;1;UF2Xx+pT|6ZtL4Q(kYG>wRdAcclrJ9T1^`Bi0M?y1 zZr(W2=Izz`dIicH;zR^W5fe2P3aNw5KeN7TVg{OpS$Y4^jX0PI7(d7WM)TsLDjPDw zWK#WWU*B`gOdSRB>_q)X8AaUO4(|d*^r@rw!2UUrA&hsL;VFHZl8C4gs;&z$M1!W% z1l~Jqm(QE-i*ueya2AB{-fJ??&_ok}6Xz1yfVmVILK{-{^G37O;}{2^oGoo|y7nsA>H@XM13Wrq9Vr8H`~Q8AXw0|DU2j%R$*HP*Q0grZ5qjsJr;& zPv5`4?KB#``1D0wFTHmLCLgEtT8dFjK)tJ}iD1Y4KjC6wMOFXK8WhB?>@?TdjQwvQ6uCfULw#do2^2$W~0ck_Uf4pc-4 z)TxbeMpp-X?tL2(d zfQs(0^?7J0+jPh)9@-gkYb1`sK2dH`i;rnV~p9=b3ErVHgBm1Bz3Y;2!%Q(0Z+k@AMlD z%Y8)|8r8jpGpk$V!u`+>Aaztw7h}v#MPTs*-9*v0A~9QCaSooc!QXn{(`5e{pNhZB zSlW>6KY?OF*ahoCOnoB(Fk*fE*%>}R#~in6!HILu$7uQBXqx-vle0fw?M?{&RzaXBT4IgU1W(yQaSnvEcHGZh8_L3qz_=;*}_E4Njd(0^7kCRVmiFYoc z0fYj|>{CZdMDyLdn>A)SJ0nz7qs4v)kesQq1-ai~`|J|{JTR5uw|Pj0Odd@{UJ6@z)p{yclD7t z{jN-?T`xH{P`0quVN5bw&=p#FOlu$^2Jh$c{oC7>`6w=j#Ae$V3gDm$1^WF>NiapF=kl%RhQjJrkh;x7OJBFvJZ$5!us?2$aI(!-sJ_)hL8^T}Wf%^k0eXIhTL)++Dg(!%C60c$|u)ds*Hjzw;r_aHh#A-GHX5SAHdchq1$>kDeOJH8hxfXZUg8KgbXzN1zU3ECqz>qskBwZWd|n!5n_qlnCAgZ_V7@McP>P( z%>CuvZF4bS$8-%?pW%>V)Kkx9FznR^N9GJDq@K{?8igF2w?H<}vd* z!s0XmIO579yx`gSnWT_Wb?=vh;#aaEOOIuqdVu@i&%27kBg^_-y}`~7A${2Zx}2er z17V|p(Exw;-D~{t=9X^J8C~Nt@J|Y%6rn0yB9>HZHzav5qbL23dVq@0Q%7<2rqNP1 zk4byq%LAm2`cBj5Ns!LiQEtpW@6BsD$`vl#*Qim!REUhdU)(`ZG2XTA-#YdoNsQ!$ z#%w@NB1Q|*G6TVkWFz+389$qICZ?eGNu3Die91jkMQVFW?AmVT&wfeeJ>cGB<^%ha ztx?G(k3`5$DB1@tK^_aLJU9+IzG>Xb1;S2m&|)wzi4$psR^no$heNbk#pu(yZwpB%U#0G2Lr`^8XzOEX2K@j z?AGe+#lhqa2`j2*yp{9Ypb8i|klw<*tjwq_#KkARQi8_CjK6Thl3kQjYZ|~L=%|A zWG=~=3=oXE4EVo)`={kvs0$M?&BIfT$z*!x6kf#@8Ye`ct}pjFc59ws`aPnLexa|i zpj|ISMcg0D&TufDN~J+c_S@@@&Uwb51dS19pi*FBgvr1R z-9^iM+#$fJ%71XZimVXnX8`~O@2}S)j)V%HU!Elj*%Lg}PfrBwpbp;fY~BcxLR8=1 zELowl6$)_LUmtC|E&tcLPu)$4?#;#euP1JQRpD;5yc9%OD(fXHI+!5)0PS@CYK?#V z;}7P}AkKMj7PIb()lxp1=T_=oRIdNUf3p{F^|xxpYJPtQfdtqN2%#*hvs$$TuO*Kf%FyLW2_Fb{P|>byXN_aGd^U?O&*GWfdc{X*Ot!4v z6E7f+d;QJhz#|+?YCQffGlAVbz?Q6lz8^^2hx9`O8D}93OZp(f>Q&Lb={{>AZ{9Uf zc|-Tw--@+Z>Bp$r+lJ;K;e7A)$5+gG-v)M=|L*H|-@FSq!Kq(ZnTT^3Kg*r#!!21d z_qka&jX_TufFmEKG?-AtlF40Of0yeP&Zl6^%f-wYM4qB!<E7lPXrMS zmnasr0!7<61lU zMrXEbPvWm5?k9Wa$21AN5th&W_6ne>e%D? zGaBE>w5BU{Ksv^nIG4#fYB;Y*r+j>a)O#oQ+v-iG?xVH@WyX+;v{9B&%H+%ymMaK zzkktvK6}5=Xn~+JDz#}%)?J8ZstR#2YBY(NRzCCqef2+DZDd#wtPlAfRs(Rj`}%^D z{oh<`ld3Q7tVnCNeRc0o-!{JwIPRlRv2m&%U|7|A1P8sle`1SP5?+>MUpfV8LpV%< z_w)7vDB#NF;Ii776oW)ngIema9n{k~rmQwn4bH#->(*gqYR-Gp^HuZ3|M_44yl&2J zFoCK8n3t3 zVXZS^Fi)3=%>bg|&AXc*E&zaieko0a+!4L{MK%D)@)2MBc2XBrOZt3F!>n@D{p2!oo1GwCc&Tb9-n#2Z;H5fmK8-FO% zduS2>nx=^{Mn>1yCZdG9xwXIoLu4rcPZ#7dqkzZ4Cb!2)_AZ^HR-l)e8HyGy)s^jS z%V3lUk3et!{KnkcKl@>Y|NKv1-P{I?uMq7*Pj>p~1> zX=2_5detVr|MF5^oX;H&czSMvf4eHA?Zs0CU>CaR|d;15tc+}j;xpo~{f zr~#p&pln<-Z)P!efpJCox9{Kl$N%xW|L{Nm@DIPc`j>C6*_?}OLW~dy4U94x-&Z^0 zwC2umyVH$Yv$l4gIKE-TE*;FLIcS3W_eUiK?gs^%I94*V)4gf3TMA9?VC&zp@;FB0 zy9ME^PTa766@R+azKMWj3>KrMaR4m}=p{U#bzeSr-dY(Fc4U9WgCkOww&Q=;lAmxe zmFa`tFS`j^AN>|)al=BDPG4$~Su=A^V>G7#ak0R!KAnB>%zd(e2Xfw<0t$7mpr(Z$ zCqLsw?5^tGgJ}TAUs3xn=ADJ_wthf0JI`obe{reL{MxZg3=t8SF+|2_W(HVZ+KS6e zzL$%iSH46`#@|-G>5*P=hp+rY)&GGmn&_9)k$v;HvhNr0tn8|!UsCzPZ3e~K-gj7_QtQ)Kt)Qhq$R>rR%#+v>|L@Y47b6QEsp>)Z9rJBVII{_gv?S3JMQ z^MCl4A6ELwTIL;PKAVU2s!Hk|5OVE_Ju(#i+IvFC794KVa(kevRt9jrE&V_wEj_+q z5|ErIKog-wUh(Vde^0FPQ1^2uw|YxJ*@MP4+~EaOlmyHX8@&u5y+TDr63l2_jL`^+ znVAKL;BEOgpUf^~3JS!5B+Z274v?%7`DuHA2dWYeHiQp(^Zi%L&Q^ABhFz_V zson%$IAizRcYpWk+}pB|07em|l^(!yb{`cC|^hBW9Y*ox3+3Uxu( z{`9(ia~nGXFctAm0mc}%p)X)`%#$UUywI=T&0+@|n;|mGbls z@Y8bvN=uYNfQuOxjlhi1n{~Jih=mZo%^BeIxc^SJVd-<6MKo(upaTQt#57&Z=PGIu z>tKKU?$uJ}aq-zVZ{wS_Yu$O!xrr0fSJl6c02>Z`&5D$%^>8-pp(;H<0q?c9>l(jg zpzjw8lwq=55`Po_XYi669p~~}jjR8jiogAg95Wd|Zr3Dt{-A^X9tU8(#2=?nwjw6a z-J7v9c9BK_Bu1M#^>+R8j4u|Lp=}TwcOcw%r@D7{<#c2ne@vb$4oTl0{kHn}tLH$% zOURTI5}hG-=G3V=i&R2sjle5tmNJckb!h@CMn|};wGsJQnXNs9yLInK;Qc>nE3}V8 zBeGonrYyuF@KnYIVUG5bGx_WS&aS;M#t5iU0?|eb(fXNa67WL7^p~%e`scUZAAYG&;ERVgpf8 zW6l7kVA=jF1lQ-3=Dag_ zgEQ#Uw*EQT&h4r7kG#cpDpGa~`nI;X2XpRrF@HwQfItxy(ZgI={pN)}bGOQtmSWVk z1=hw8VNpn!k+r4oPK-v5@_yJ0G)$ko<+90Rq>ZfeEgu=MN3UU~R<_@hbg)_zT;#v3 zJBNxUG(bE$6#W?aN0WV_Ux82UGY2s5Y3t+@b7 z@tYs70%xn}UtP!R&aI<0i@BMps&}y1l_fW`eUK9artdurf6HpF#S2JlOP{elf3FGC z%0jN_dh=G1GrvZz|J?d861`m@| zHnCiUW)xt(HWH0<-Z`N$1j5v-A&SB|CCy@Fh?@P_k{{5|EN|r=Wc`PmnuO7E2hN<*;>_`xZ-4XLadkCw5QvEjkk$|k#&Y@#udinsa!XlW?+B09i{AT4 zj`pWI^*qD;qg8tJ4mlZMD8YLk+$bA3iy_id%}Nbc85R{nsn*WX)2^Tbj8OlcIrJT% zLL5XaSXdVzOyCzkEb+RV{prV>Tk{a-oTC|bcy8Vak2w0|JSaEdR9~UEYcZOrP+$ZV zSIaI?R24B?E*fXK4tbh~?8#ApELhXHXi*ih#i!3NoDe`Td4IDKht44CqM7$DgmAhF zC*AqoU3-UBV7D>iE`R~m9ALy*?7B`>LkP(XvJ0IN1iBCcWQFGIAFqSWh_kPMcpqG& zUSR7+#nH@yIsq-&qiN5%?7fZ)K{i4EPSmM5)t;H7DozK=X2Q`DrsT4kt~3=aAA`ae zTA6$1rb4Aw1@zCH0{kVU47F`&`4RO9V#A(1f0R>ay@WcAlCx3YwmHx7oR&T!VpR8& zfW%}&A%pLynE?pV2w8X587UeK5n>GC_T8_a&%QXbbF2`9b6!=Q=;rU1PS&QYiF0nE zaZC?vGfdcHrVb2|Ae)!{FENrL#Db7b+7tWE3}_n7J%O1^Ug$A0M(13N zA;xHCDSv8k{jXfb@zd>MyW&muuXP8P!8^7|xdE+=WhT6Dvc ztyC)mx;|n|#hjQ^IuR8ZwA1rHe0TlF*XuVc?V==E<1WI_F8ObL@gKRqrByw#oGO9% z>2c3T?4ox{G7?mY#MN>|XlT1d<6l0z@B%X!33-YnpeNS=sA>o%3KHJo^2{{~SXAei ztG10WKvYOZW7(@tkGuBnQVTur+U}3PGPYnYkETg}YQ`lD0F^p^2hJqtTCs% z{PE4QV``G=VP2vjI-$TSxMdv1UN36pwkmK-asnOfpR1IBECQ<~0Ww~-bqI>j*ngd7 zT;+U?hY9^Qshb0jI=T8mNOl!xy#1L6Ejxy9rD>(H<|c}0jt3s3z%US^U9`^FMOcJO zhSdz8p38IJo}q0JRFX7DA#RtJ_xOp&{ovl-Kx6cC<$<@!;||hq9XtlNRyEj7r@Y%v$FQ+3JL5IGjqRx!! zoKv8^Im7z%3%PKuGKA12z+@d_m&iXdnFDYf6JA~Fd{m(~@nH^f=>hUMqwWFftOAyx z<0`vsNb?n~b-?IL{QP@wCd8xP8rbIt_jJ1QW;_apz+_GGo2jjF~Z z)esn|>S&Ax`o)je>u+CQwR(0Fn(yD-27?GZpfr`}B<4sx8I)U-ResYPBUS$up;Ly+ zz7pV<6v+b5U{VuMtBl?%9Zd4KMv^be*ogw;q}se;>@d5$<93?=YTd{a-<;3fDF6{=H7dV*>#U5;So>q=7qGY#NQK?F_|O)0`W#sfm4dvT={YyK07zZTPM*I3`TJs zv0S%ED()f950zix7NOVXmO8BMyYGuF8Ykd^{v1Q|!wrw-Vy++q2G7v~-nR;5@8gdV z;d4BTs9J@d&|Sd$sq4RHgOcGl899qel@p&+hnX-FC+cC2QIrJK`E_((y^UXA;fL!G z-6Dz`*4`sLSoiq6?UyHQx_F-|sD~8LiPdF-04wNXU9pi!JH07IoqIZXf#6r_?J zfK<}7N(Il(W{tqi2)SCqfM`hkfpPHRsrtXeGmjkq9r5O)g`So_H#0M14x$&5Aw(0s z4f4&8S8Fyw7vH^KUA3fm6;-6sYZNgRx@v*$Rkl^N&x(#ejr*;Aflw4$RT41!{b}M1 zU1f?DX-|bbRpaJ)gjLzjwj0bq=s=!YUo02 zqeX~_I*7*3g_~zh`1H&!eCJ`poTdp$69C;>-}HjuttPEFp3by&qH-uc^Wc1Z>kr*k z6+P|*<58>s;ck?k!4Cl>S|npni8eA)A_d}9op(*6jWTg1SW>|9_3(7@w>u_d9Iqa$sxaqVKOq^LLOsts+3lVL5# zZGR&EFokwQjxPpx(oXIx7un+!yfH*&s>JJ6Y|JC7#>EBxS3#8x+@nY}2Py9zTE)QE zNKn~9QrR?w#Fy=00G_lGo~7+fMOD-#XhKq^3ba|gZ~Zs#;#Y6h*U@)$gmSxs_T8vK z4~GH|vWQQ_p>)(@qyXY15GJ@<65?T0#^>jYMu!5X$m>o?z=xI=Ri%YYs4zUgI8zIt zscYU`-I^jY8(!P#Dtx3q;l9y>gUA2mX=XO8Ap{L1)y=+pvwU^c>ipSF$M3I~D|O<$ zLWK$nL5TIxdofoYrRr9}L@ch)iGEWU^CD6*-WA36zow%0Spehee~_A?_YRVpi8S0c zl9iD?2!xR}P{#fb77eoTan~MTlXF~kNgLii83W9m*lf%+Ui2}jq{R%&wrh=Pl~b6; z)ibyL%?o#->zQ<*0h%^H3b4(*d=KQgzeqaL*7M+1da#DuuloIYjTuwzKKTnW5bDG! z$P^bPCeGBgK*x;nD^#JyOKY$c)hhDlXT99rb*ghP|7&aN+*aklp0;7vRkvkmCTgHK zry?+_IsrY~XTJN}7tK6exv*+fV+aO;Z;AQ8^ zurUV!25i5d^i}2f&=shR!%Yg%Q~yMjVT7Ce0U2wwMt#MQHn0hVZS)M(L>j`ItuBG8$gFQS2vm(01GdlUs&uf zGdxZ2$|u+WhzLTA>HcwwXXmPjPQ=Lf*ULbN%eImt`isB(c*h&{4s5?Yn?d!4?5qDY z3mf4>l|hb(biuy+;a$twZIoBn%lB=F&QToUATHI9Deh8KMCf|o<-*#mIQj#pu3vL? z?Bblq0uU!UhJVt2(69R(rp6 zl>o50&GcU}vtTihR0Wg<#xRrcavncFGtZSGW<$Nca2!`I*d6za9ez8za(s8a>Ubph z$b$Z^cDhe$3#5=@=wbhS z6$dZ5J%s)vug~oPwAI{fC_j(?e#JQ%-$^5KU`n8LOMrr5Kr|G=S&@yN;-22mwEwJ z84>XZ*EOlqVa6j}nbhlAkvRL+zIXPVz&U&N@_J%f*AS28|9B_C z=u_$easmJ(_6harmow%BH<2VD#mx==^5g2qWp{0e9^zBSo`fKk(4znE)2j}SIr95G zH}~`a2dx;7EbjNj2<*8jw*HbL$c92xCq9{@sT0Sru$$jJ_ZM?m?7TXm3r4DjV(v7F z^x9ftZhmo35ZJEjf8x-8%wa!vXuyp%a9a<6ZLUq2juzRcxo>tI)ns7We2tP}FtWwU z*#OnL=Ms$NQmlbOJk%v!M)EHaif_)odDs2<_3G8zYcuZWaNemayQ6;K1KxwbfGW!f zTrUwK%&3Az;};FQgBe7`EUGJcVW$RQFNkFTobv*;7-5)uTrL`r(LmQ+-@*uisEVDg zy`OZKrNERX%n2$Yu?mj`V+tNJV}!dk{qw8$YhA1`d$+c$Rit=zO@gxl6QNcs0Bk^$ zzpP4Pl%vP#N7X+tp8sr;`+J-|+jm6!9l=C1wm>#YS_vHt^=!F?Np zU5k5vBz7wS@+mSkiWx=Vgh?%%C|WiI888|Dvoq@$1EUxl<3hR@^Z4={E-syzs;UDt z9Tx@mIU62CJvaZ%?FE)>KWcY1?%dsF{p|18RMrp)w`YxvGUv(Sc8zadE&u6{-~Gcs z|MB1c^5$k$8Cp?PR7xykI=K89A}u@IsJN&4+|otMzUje{qJlk@=q|!r?Rh$QaSFwg zFh(f6SC@MAnHO)XXFfceg)`SlY+LiuoD z8KNN~4>Ljfw!^B6Q6*?lPwBdOw|*(NfA?H9TsI9N2#gVtN@^mNxUIaVtwniHZ`fM# z*DstlGrhNKISw2fBkjR)H(kazzH;aO;Yo>rg`!lUBbCr}>nf?FR5_q#&>4RJ{o5P& zeCWAfg4n$aQeA_1q=hi&=fj&6}+a6Xu_C*BLHuEfj8>5R&M&YsJi>>;kmqqOV zO?Gt)$B4_vL?A@0wV~HW%`4TFzg4YUNc(r6=*wo=@YW&1Xpw7%6=D~hMNNjiU?gIW z3!~8>RGJ!d9!eh`T&4!H-z_>>2Xmmpj9dqf{OFg*$;!#Gky`Th>C5Qa&cDBfd$nb` z2rPHSW@JpiX1`51L2}1clwBYv&zK6GqHGqKm#R`*M-0DpS-~hmr%_13=(VU(%0EZ5#x^zMJ zA={WaJUe$@r3;bjZf@7npw4BhlGEdeJ+h|kZ^?oj4|DH$1!WZ7lFwXWa4i;RW|0<~ zrs=xa27LSKZE%a=&%S%NTy?7c9Ht7V0zq_QiXdVlbPzXabn7bosExMha!?Q8yH1n zssloM=4{60-+y+l-TRpun1tHOJlxhSh8~}z{XFSSO!Tft?-(ZW{^D=P$ItnGFIRCH zX4UTQ{r_o#PMS{U9TpKYusFnE6b#*uwfMBxx1)!a2hRANa-i@1+wS5JZY9o*pD?(b zi69lgX?1G66YG4q`OVA4Le`#Z2UB%$z9ZUb(K5X&-}nP>y`a29`Mw2K>&LAJ(ywJV zc>e(Rq}^W`vU?}PuF~)i^=*MCkO0KIo)-Y9*!<_gmkh(S0^Uncl-Yg+iZYmWhQLS- zOmm>p1oU13ic~qg`)_zvO4dvyqfe(eKf}CHVSvbT-7ec?1a!Lm zKJ1=)&=Eo?E;>hz8cc(NVkE>UUEsQnCNWq`ao5+&uwJY2_O^Zd_9mLBdJ;|*v`qWS zynJf1PZmpYU@6%*(ap7zc~w1#4B1{&^tpI<>ZT9$_`y-sX|FCo@%+(3q3CsB)o*ei z@c2WFfcAeys1h5chXiUc4JO9IlVSwvjV|T>ng}UA`X<8h`m>jMzKAnV1)VsBD!gJQ zn5E;9&e|@4?|zBJ9>0_a2B(Ik2TyZm4%DQ-S9LyG^ePuK_xZC%x+`S|5^Q30ZERcXqA);6P10^b4n#4K6)9-X z0&*n7mz^j;hXJf)Th?>WjEGR;;%w%W5E%6B z`?j-90{1WIaisarF*p3d^?gqpAcUg|g<{kQi%JkQ5i?QOh|^#Jignv|>n;TQ;fHr2 zI_DRqmIf^r63&3oq*spJEAe1J8 zWV@`!8b_a|ypNOsX+uUG9e4+*g6!E=2R_F3{72FAD)UV#p}bRyOM#i{h_7MT+e1is zDIF%6z4mN03Bjgo^FT?JB-d7`qdGW;w3R^ID<%%#n3n6)8`k8Xf&#S^>%5JLQG6>YF-~uGVZSakINKLCum8Kct7;+K6t-U`C%n9U6rUXW(57|M(I1lB znzt!-EMIGZ*=W3sE&RiNd&=F#AWncN)zc|Oz%)2; z3McS_CT(r%j2hvCE?(WVI)4$+z$uiGG(l$;0ZSehmgdx!jk<6*z;0I!@Dqj!{Jhr_ zP?G}2;nn*ar~~0>pFCS6pYHzp$B1=0Uc?Qrj9llPu&eSzwl(K#bDHwBHz2099iX+fQcv^a3tkFT6Ma2B$8mL$y!I zQPe8FgDyb3iuGeSJa-LLKGpiC;{M@{-~K!L-aq|Fm2e@bsKSdVl+aYn_7SvV78(_e z>jhSy%2ZEUR;K_MgpZL}l?00#FilC?%;k`|AtkaWmKAN}(V+X{Cb*y(LPj2?!- z-CZi3iOW-+5Iiz@E33I~nEDsDXh<&}=oR4oZi3AVU>I4X*AClO3fYD#^QI<}lPjG- zNiQO_6n;td@bjBa{>Q(5yR>;5Xl8KQSrh?iu;lWfG#VpQ*S33W{>X*%Lp&9-Zz9KyTCQ7i*H zD0u;;a7P(@JSsf^3g-vtWnl^tvd{by8BiZB+E^|1eV-4I--IPL0{H`OhhkA?S{QmJFxy3(E*Z%a7Mh62IcC00?AA?SJIF6Ze2iy|P?=9oRTo|>QX z(+vVktDaemF$&;-XJ?Htibxdy>K(=Xay>wgu2E#0tEBG73GNmmC?G`Wgldu(CQYta zu~SG45zYsh{ipx=-IDV*ihA!F2Qfi(3Ru(($>@-!0?9cu~ zt@VBNpW`7_`XMuGg|VQTb{Ex|Y_kuM3Juyb%ii7ZKE#El?sO(6ae%*apClv##AqsP zG94)S8meE1u#O>VI%vUL@Nxa}oX_WR#xR30MvH7_vuIYlL!zjaZB{pkSAye2me}C| z`hoh}kI+J&9zVN=VZU+hF>B*PTyFLN|HD^$vK?IU-ePZ;HlH;iw2Qe{3ulfO+I=#& zxvVs<7^2XVE=0DmBtGZZLY9S107*qgF_ z@h?ATHpcq4uFaiy1d>RNk;2bOf7nCTFzdt(YE)gG7NsPw_T_P3XX zv7k^}%GM3$&jjh;G@IY4c)KgNIQdUcM#1&4o*Yp;NsEXk5b+k3k;%C(8kg&K6@rNv zD1@`tKn$UxvP){I+>(Oai|qFPSEADN=!{@Q#gx<~3Jg z7Gn&~3u(?NVmIgd*Dw5qUoG5PLR534KsA`PF-C(C5HrL?B?8op?2GVhqd&pEg#>NN zFRc&V<~DR>NjkEhwLLHABqL?y#zxXg{K<}cHm#VI9sng2Y}7*ZzN_WSHob~icO8k} z-?LSb@=-($&Y%Xh-b6@}DG=dP(vby$IVVwciTVHf>g^wYSgmoUP1AL&MnuhofrtQ1 z#3+$8&}f^<&09BXOcPOL!VR9B!L7^YF?Yu=ZOw@U72p2&hHjQ3`F{1;1w(OFCX?`& zgsQW6asj#%u31%VjpBR;kEmdT+;-MQ1jES<^ib2kM*~oU(*=9J%i*NJc@LF=P@c)< zDXUp%vFq5mv;XgZ|9zM}zl}oG%!Y(zyU!f3G8Dlo=n0dFoKsH#YI9{7Gw*@gd3X35 zb7D`OLeNM-@4I`L;vRiMSZ>SPN>rGc- zL@=ylg!3Ur8a>3v?wRjCyTnYE4O$0LxcnO_p}AwiKn9YDnbIZ@IN2w*8}02)4t_{c zxE~4o&(V)P&AuLQp9Wa-$ z!1~uO{T#~+uL!YGA3}`8ZQFK4EEs=g2||zpt;S=>$sLpPu8uy=N3Mr?8RwDNv#gN& z>8hfIx`WCuZllG@dVm4-XD=ShR&O$7(xXoU7{3cEW9Xi1l+zg!{7*58K`EMT=mgnF zR3R!9niEqoCm__Hy?EEUfB4;Bmf|~V9>m1r`I8Tog39sV|0ool2Zmt$(r7}2!09?- z9i&Yk2aHr^}<4k-=k;0nA)d|E_{vtq^hY0NQs4KH?3z%N;1qY68tb zp}@qLwA-$Wk)$-|U%kffzFl>0q4OD%LZF!aXT5LX;UxD@87x6H?Q4llnL>OM&oXHM zCQ{nk@hwwZ@;WP3ILp8Vsyhm;mi5Z_v)2O*789Ex>7x$nUM1sx^sz^>w<6`K-LWCZ zv3}2?11zzTj&NkJ(_m+84XrUbYboJkoU{A%Jia{d8d*EBWYm;N{~D1|e=qQWiZ~I0 z7$mArU4XD73Sd`DaMzM+7h<|~R&c8RPmhOdc^|T_9-|r9>6xXOUa3$ARe>myt-q8Z zXCh9jsE5s^eYvpD&c$`hIZR^k>J03RZL}^XZy5-MW_won>9>GK@!#Nhz98Iwj&@Of z`q%brvKGBW@|O@TRL|bgVH6iL5K0H%2n<#eJ^QrYDW<1M3piEnO|I7f^eT;H{h2ib zDHA|T4!sFYX+m?55T(=e-~G7${VN2Shd@>5oKFODLnJ9tg^m!*Os)g@49DmO4<7K7 zL~1JlYQXy?x(HGnxM*;(=*5Aj2H<{eRSut&{{IAom?=KFJaaG#BIMP(^~nVE;JtTb zT-5#UX93J2X~qa43@~8i^URJn%&Cd8>O&2{tX=sw+7ctO^BKfDjCU-w+W85rHY&#+F}P zx^r#4tt7SrBg914k)6?!{{jRQ0|x_yEd`}aN6K=CJ?L0Wf6MXVR=WQn`Q6b?_4Yy9 zj|E2V3q66e_nngp*PXsUuxWi-hrv(W>iR^my4Oen!<0$Zjhd>d(B%zF(VSgz{vZG8 zPixFg<C$2#A#|1u5E7l9NfxK}*nMHu_Bp(5~R1Uw98dAPRnXb4xf?=NLbGe&H1s zId%!*z*~D_$L2|D0IL5cm^YCX9r4-oOGPk?;+j`)Z%j`Oz$4W3_gf~?zf}Y0#*7bLPsX7`9WS383D&|A> z{xb+nKqCUNj$B7GC?JhMyVaStpI_=TAAGn~vg)1y%|1#ZD&iE*L5Jy$VnV$=XbHOK z>N?)ne-EI$-^cy*_$#jOA6U5G70AB_hpoP^H{WObeN+Gr3egKrhbp&HF=`joe8JKbH1$_&ARpJolcg zKJ#(VRsl$4)PWSr66`fWa#2*glVvKuPrs^dTTJ?vOv-Kr8OGYGa2d=Z97@Zk1|=mz zNfi-Eg0GznHFaecml`}j$$IK~dR9&vS zReNdx9@;1D>GBWS1Th;(7#YnXE&0hLvbc71ouB`wfBN$hvzDUH&zyI?&srYc=1{M~ zNw;L1ko=NGMn!y~mkd#h8{2IUhD-0vF!62JTATKJOuy*}WNP?O8}U`3)RDnJcS^uL zehJ`-fxs&YL z#9{Be)0V$qOo_eD=m-0RpUO(;^!R!80LR>qTM2A;HT6HDc-=0VuYqJuzA~B}E=xBq zs!rgXcvaW0n5xpiTv&c~sV^^tVd!n6ULZkOC?+6(V)hJsR%)M1 zZ9tL-r0PGb+9(P&ixh#Io4fe0|NQL@g?&cwhO13pfZ7PYuA~6}RS6_eT&(1~6}Mc|KP~q#4}X_or>{p10*5 z()p2N!~_YM1zH4?Ntsa8$hK{TFwB1UZT#cwb)3yiEQX-T)|1(IQjskYL{wd(0;OzW zO40`p9Q~XCFs}Zm{O`$csJpk-t=s%i(Uu|dj$NF0GXLIVi21m+OFyHZ9Epl+o0cI1 zX~r&c9oZ346yiWi43~3#`AipX?HGK|am}TNBU6f{6XuML+{h92`PuVFP249Q7^=Ff zaoPpJ9<;$ewGDs8J;1}Su}8UE4@Cs-R?Ywp>Qz$)Y0fUmH>avP5vRqshU!GU2O8@< zuRnX)EM`z)Bq>UfXb6F!Bh8RS0NiztY}*v%AuQ)nc;fDIv9t$UdH?{MrT-3aXPYhQ z&<~8^e3i;(TGAwWOYPUo$%^_~b?}&WxerM2zt>{h>N4*i03~^YYQN=Z$$UU)JId@& zKdyiOovjUBY!eciby^aDfJ!I12?^~X@NBMrQ?sd4eSdU6_2yknLmU`iTwXSAa!O=c z+nySL{T&s-ZN%Q{^GCFJ*(@; z8x%m|g=T&s#$G$p`{`uXX>nUighU5*LG(J{)e7HS**p6G&wu;<%026%ifH4V3Kgb< zmw)AeNh^Ws$gVq5gi~4@EMN#)m#NQlZ;3et=u=pez_k6UjY3x9ops`29%yfCTO z&t$SSny02)LUQW82arMt#i6}CF7Y7#J-q9bGg@2mmieyq~Uh4C3xJG|aq5z6D#o#8?ugk*v zR;Ylg^50%nJPNZH)bq@odD#n>)6vhv>O=z)5Pl`+|KT71^vzXV=|#}SQj|b1#-O1R zV;mJ*AqVRCgZXzK0=Ifn^=DZ>b0+CnDhLWKlhRZ7h99qP%r$^`*e91~9#GPvm8;N7 zbyT=Orv_mEDKIq?lboA@WP2bKl4j-wzy9pZbzKA3!GCvky;ig-BRlU>R8oveArRG~ zTGlZB47V?CJns71-HY|K`cI3L!3;4IK+`_Ll*pXd*ED@(nmz*|cA^r4(tH{OvLMGl**7Ha`f@({EL&ifsuTE6S`liTc5x0w3( zhdo6u^q*|R0*u_{%wp12Pf!^WqiK0*ZAn_c6AbMEQvL`E&zK@c5NKmG)d;<9!%Y`D zs?bR!LN~M37w6&2^X|fKeOP(4hobjaf2uI^|9K*d&rraiRmpD(_}vVImyF{HOru%JTX|Igl^HMx;(>7n4a z)^bNooXH%{sVY@nnbc<8YU_vo|4-NYsb#lx`}Q@dN-b5@8FC_%a}0p{-s|g!J@|G^ zfe11)NoK%s@&qFg2*BaK#&3vFalj*vIiC1sKfdX)BoNb4p^Cd@Z5VN76tb#wQRH*( z0k(d!>wxe1^}Fk|X*H&*nI5KTLUuRLR)!V2vv%L>G`uDNn*Pb6i(DrEYAzi$Uma

    wrV@M0ad zReVOWKgpK4@|4Bas(&&+p)_dv`eU7X`0ar==LnhDmf7EInjo?<{F)@8r|wpuLNE&5 zlD=m%`AE(L>Axvqq`6AXN4<&F+>}QG%|)H7Z3t_rp2vMJk*UqeM@GOu`jEzB^w+gx)hed(d|dBTbD+ zWs(bAU6ex5#Xq&Tte@a#u=cN9%BiV}FrM_dchoBxA%o-HmBWmUO-Do7k$DLJOuK+v zCbo^S%3&k0?7%oVjZbXshA5UwZ4x5lPScK1*Y4%{^5^q$=pVk72mkgqT;T})iAQwS zxkpt|Nx{0#|0!BZ?aU+9YP?v(bSuYJRe7VRT*~Zc*T`}E>Fvz@n-iky#1MVR4nQhUCHuHMv+?OEj&NnZ zIx=;qXjeAk&B{Atr4nH}bGm1l|2gUB_^Y;n}I2>DY7VB#n6_0?XhLs@lis$;;2(qkHn= z6o{(MucqmXtxJF%s8Q~D`-wP)4Zo`T-`!D^+cp4OG~#NIuT!2jtd%7*r4D6gRYXoO zZ1w>n%}go@Rp)Gy##IodiVGs(m|O;=N(R+7BzruFcbyHxV7)nHpsw`f`*-~5!rrd* zD&$3mnnmZZ8dWtVYoFKgAElg_GwZ(C+G&18k+S6omf2J83&~`nhqv$H+PkR0PZxI5 z!%|7@t*zbczi^JRZ(F94QQGjCnP8~1AF|>E+@WgD(cw{#|Mf2~qHCnSdcS;oK9H)=blC_h zGIGtCW%B=O_ouDF%G3#Hb^}OX=!Wg?p5^8)oJsYC!8WMq)&zVDH2;ClIC~ba*Uneg zRD-aI2PF;akyn9(r(JZ~P=-@oJvsIVN80DAb}A(zG%Ed43j zrHK<%FPAI|NL70`!{7Yz=^wv-`p2)I{oNnFHA$B>03_>5X0=taaSY9V(s`sQN%{^jLgetPeA5}kmL#|s zRi4=9dGkfzk~ZO{fo6vNh(fh=gf95`)g@?f2;+nMCr1lp4{GKOQk_K>2RFHs0ll^d zOIa&(-RlZ~q(=&1f+vp_!a)H@_vYPI@Vt^sy@E@^bFhH)+WF*Xe;yw$5p#p`rLhx4 z8F$o%*e8eI$#|J0+~H8^VswZ^;D?uQ&sQA08{B?+`A+&1r?|TaVjL|K)GB;`8ud4L z_o@0XBY8|So9yHQ)kbw;z53@y_nuCVUCc1F)&GKgd0TO05Mi-S1e#GI~2_G&-gew(-a7q-t8pComs9NC- zwU+3tc*0uudr=N5QDxnZYMVOQL4j^R`g{+$Xd~LNL2OP7ljdQ-s^n=%gx40uc1%`P=cQcj5Wl!Nnj@ zlMqIC_SOrf2)k{`ESuZo4;U9p(<}WNqsiZ5dXg)~)c%%_iY4x4tDBS;1M%kkis)2S ze0=KI6k~=zXaS zQZkidi}%*njCU1BK7>K`Y(!wV2kzoQg0uAdfZeA^so}Yy$mzY9*urwE05Nk`~pa4CX$^L zjo}Exqkj1M3}-f|uiOdiDK{K-=8C9CDEN z^NUJ-KluX`3iWIN)DR4sFr|}y0H=nh0!(ucVA7JFK>Oqgh-^WLni8j@dTV>hV~ zb#|hWPKB7bNYLZ=WB=1T{_79#qAtc5v`b{~bWL)zyV?|n-oHKG^~jCK+`bz5TmnFe zpFo2sOWa>46G>Z0Ra}g?7)Vzo78W0zfesZoy#R)$D}|*QylViii-FYKfu#u+CYccx zH31?{dfYqdg^?~}v{!Fg-Ght5Nd~cI2X6enZVk^$CgaAfkMuQg`PvtmI?Q7H(nv9+ zU5k*Q*a&;^=KM19eDKjue|UMR-RTg>gx3K&Q*g8TucLjDHS5GSP&Po6HMhmu22{HK zECPZ}<-P_$=i1=Ey*S&P(WaOi*;*YwL0n6*t>fmNexMb`E876B58=vlI6*QxRwKul z1{p#KJ)G$9Xb~PCbHS@#B0K`nu5)))Ib{5QJ<$Iqle`001c%gTyVf;das`_K$vK2p zAv6E`rYf*&zJn`i+AjfhcT#%iH2cB8Bb4%L$J4q-&eS| zM|H7BAmkMogR?pL8k{#q`s3VE6^A|6P-njcwPC zEm*A}WoBQIzAsJT35cp)t;Q(FfBx}RG#j=1;q~(U(5aoc1Ey36y-Tq&EBWM-eMarw zP*1Cnn{`PwK?=T>B`VwaE7%)unq7I0WLm2KHEqy2<@BHe;zN3aO(l)}vDK3a0~05S zYaB)Zj%@4Wq!($xK&&E0PqPY%fQmot$8YZ8SO#V2G_`8biUpTz+MfMVp1Y=-`mALkIT2LSaIW5nyCg)2i{V)y=XDVtx-x zfLl;jwgRbfFbCj958Fky*>+QsMam{kAfsVCk=55HeAJJM7~DasXn-z9hDdjM!98rn zkTinJ(;fhgSkNsRu&ppE90skN*X4Omw9x}JGaXDSOj`K>hXv=U;#bMzCq+^IndK>6 z>jcuMi){0qRUe%6U%*t9>_n6VUDcfGcJgkpAKs4tp%HJKEWBJjXYV8r7G$Hu4-O8@R+7>S5F>#x|4FM+^vlk!D4Bc1Pk>pRZITrjQ; z4CcSpR9G?u?@#VOe|!0E@TKdZ{fl?2k&EEcb-k*P5sZkT6LhLoShF?xN(rhK`lzy# zQfyKR>@qcmGG_+Myl0rb-Yt~AR#3Apm!?gzv@f76E3}y*-C~}-|6zN}x!EVyfce+p z{{4goZrO}aLJYu~+{^Fl zc3=;%hYI{lYp7pKzo`#c^b0smwCfGvqO;D&2i^Gi$d7DfTuGV^L`1P6dURwlQBg1P zB#XaI{IFS!B3TDTyzK#BbL+eHQL376&6PmeY8Hmv((1OyV1rVXR$)m+I`eu&n{HL9QH z@lTg!?E+FmmWwmG5cf}TvQT3Tkx{x=@0})T(y6MGHrc1UdpW2h-(%LhJ4){BdbXnd zTGY5~L#hc7LrlRWJuJrPq(esP%JtvAf3cFqCA$}Iuimc$x&_1lkgnpUFij>!F-?|= zU3&KCn$SKaAR-fjs}N9{kr>^;W6H#meG6uvT^{vM7|lGH^DO)l9TcVLD#XjH zVGM5FLd3$jI+CkLN8=wJ_BLL0l7x*V0)%$fk*>P4yhsY=yXub*FRAzY+K9}{+;z>6 zb-C+nNy%SE^}l|&aw$XtF**S=@fg$LG6g(>&JX~oN{kUIF$Ph^mv}pTJ9p^i$qjR> z9mW)!(v}DA1oHJiHw4()a<>ngP5}{mG&K=*WU2g4p~J=BJU+Q^!;!4S-Cd{_iHpl+ za5!X)?trS5PC8E*e1aOvR$ggRdQJz%Hy*Hcz~4&QZ2f?4uv3)+dlN}7LhJFLo*&hf zMP*t6%eE=cJk=HzQNWN^0a=zhf6X)X%6)%9sGE`_#{g(Cu1F$)eh;!(l2&qQru^w6QZIvL`Q$}-HSIX z*eHuDE?&G}1*(`q6@Up16sdk*?To*mFy~{E6kZj1_OI1lHv8Bnm@{N@6i%a;GD^v= zWk`doS&EW+6_(Oyn?HO#P1+5>VP2m%t0vb{TC3GF8)8p5TePb&uD_i2sQ^(YoB}mM zhcS+UPN-@aM|VFqEaLlbA6OSJ7fKa@Dn+O&qF}49x^s`Qzt%rQ#PenaH@8&(yEg!z zw=2zuhtbvxqhIt%07^tUHHC<(b^Xy|VP?7YZg9}t5en{Z2cEZwTs4Q#U~aUF*j6;x z^Fh)SlMo?K7G~k%ZyzsCacSa$idN}XV;q80)QL#e=}FaxYa)+KrEwFWX1QadSvq9$ z^$|L~$pNMsuGIvwX-K@z=Xs(`oD%v<+KfGXvfLZOdCUbnIuFjV2SNYyn`Fv zvgu6};o|7{{mNgjx_9CDhgZuHi;+|LwnyQodU_mK0Nv!^-&hyuGn=1af;ul2eT)!A zbd1F7_gAnUfcW_M{>efC#?-yJ$KAQ;js$$|>6fjOQ?RLfJb_4^e6)HSF_YK(>QQ3W1^y1j4ny81UEUZ?2-O zq+ zY6E8PPZOt+*`!m}HDV|9zYsg(+=0dG)|9rj^xxFe*z(_Yvami~!=yQoCi()12FGgj zF*rp`g<&vI_5KHf+5aZO5?GyXo!&Af#ZJz_1qWv931vDSBf%yn+@MoE| zGJHR2XQd@*@EF~*n=BbTj=_WbFvc;sdy=jbI=xjD{5-|ee!2SoTK?`KD~Ow31#8z@ zT?OpF0P}+_NucE!ONw?$N4ZCQ+^xPjag3Ly62nU1B07w5H3UKljm$QJvx1MZ7SdR~ zd~aC2-}+)aNFzvdj{-EM|N2T{UD>(P-dbir{nQefZ8D-M_X){UpF3#*tux$aq-~M5 zGpVpxs$@$k6{@(xqX%1@T!j8lFRuRjYIz>JAU3-D0S9oKBp zQK>X?_b37vjKJ&jWh4OaWc>Q^QKx_xyuZ8V^C#OM)ym-$JOuj59raS&XhmT`vJaRf zO#%YKhzBP)=_rU0Jzs`57l483SG<*f;nd}S4tStzM97qF=F2Z6hKgq61b^p!mG)`)0(uCA3 z>@<;vHzk0q24&ec37zkPYMRt_c@0c5(Ro^v1@}HgNBp)QrR;o+tap9n^q8W{k$4;w;bmvE^*fYE{A7@b3i5t1eXEF0#+HCj(M zQxc(U%_)1r;D_`8TaICoan3gOS<#V=+hg(i-8QqBL=-A!XeyON2ub1-6PO1x{U2~z zZr1}}kqk5Un|ygy9KVWe+TIJONK%FZ?XIG|I1ewc@ZGD+L3-DY8zv056W;l7e}HSB z;IrugWbI!HipOB4frx^4SGX8FI#m2*frt0Nm~dyBDjkumrHgWC?izqY54~sIBq~Lj zIqd?FE(KdCqKr5;JbrLYAH{Tp{rvVSBqvKkOFcP}U|6>bpZ*~KBV0xMXaBn<0W;)Z zi7bg(k@+wMRxV}`eRp~F{Pp{2Co3HP{BAg3LF`zxBWc1;TOISZ!V#zBiZVTbW)Z$jqM5 zoe)i^k1=|rI5af?3kgSl{)Y#4g5{Bn!pN+9t!Y~=)9zfZH6Pt00J_UVVE5sH$044dB}GztrmSceoDtfNTFi zWAFW4L32guM#s7~QLfjTHeSFYn$=;;z(veV=ts&k4S)0a=!h4+20?T}x;{ErV@UL& zWNzx5je9r4)Ik%aU!Vk>9bwMcsZ$TIVG}t$W*)e9CB$4UH8T=LvGpx#@sjKK0%V=3 zQ($egnepPAz%%=Ho*u$o;iKOHsYHm)p9@U7N-wmo_JybTB%H5XTUm zfEWoCaJ>BL43Cdu&r#`pm$Y9pdB#Lk@>-mbO)Su60dlzf|KUUTYismdSM6VL^>p~#9M-L*B@OjZeh4dL}z_c`_C zC#r0N=V9h|+XUB=LS4j7CZQV>5z@$G<->mb>I@oJ0HQm@oHC4Y3^97u)0klKZ9>_a zO%9p;ZxHuCbPu!7K6wV`nQYzXyoT*&SXC{0Ch%YK%qEhjb^?ac#J_zBJk?%CG|)|7>B2iP8Avd!;HEVk8E^*${6O8G*bN3AV4|gY+&{!Gul-g ziOUEf9-hgfHwUBG)ezsEy9gNFQ1pI3^K$<{*^28gtGa}`E*v}dg^wgM0MQuz{NnQU z+jjvnM0xS%Y84ZfKq3`T13S11MZ}<8DlD6E{-%|E$y%tm%v$Pe@;04H5YA$;z)D2E zwg=driLX0V8oI)k^7jKrhFfQ8`-eJfkF4*IP0Dg2L?1&4(LI?MLIiT$;hXz*;^&=! zOdj=~=8FbZh>EKcs_6s0R?Kwk%>H)xhr@}&`)L&)KD+%a%YU@vf7I%qB4P>=XOgx8 z6p>hsxEer#br6vlJ;taaDmn>QmtpDecJzO1^7<~qz)guU*g+|7<;Avc=hC(ulC|m; zVp0XzqNzYk!K3j0n|pd>gj2+#!zoS-V;IKJYyjrb#@C&Fw}mYEDC6Oqcz{r3TU;Wl z=ERyaSDPy~V={_YOiD4DZ%;O(CFTOvVWCN_#l0PJ^+-h`Ame&`ibu8((px(;{_C>P=d?(a&$$Y6h>R>y67Ul zDIM~;C7#_uP!6~<52LFJT#~`BY7z}g+Ny%l9U|_5h+U`SIJ|# zAd5~x$0YMV?V$_epBpZTjc!`4>?fR0N?{GH4MkUB<_etTnJH2o1F~jaE9B7fpE*b2^(_l!2Xt6pK_CYFNYk@+bf8F_N8j`WeuK*3aqM0 zM}dGUBJthN%m3}a{rUg+pWpxa#np0vDKuX;>mXP{@cWel;;lZ1gV1ibIHv6wV)hwv z^RG;r0T8ZP1t;yuzF&l4IO+VJjbEK&;TI|~coK@$A@~@a4XP|L@Y0YDZFg85!KD4a zYv0TklKFw2Dtk(|>L9tcu}Z+sG#t{>3``HMZ>Q>xQ0P^hGz-m{mRwUDd`(paslk|b z1&zd>I5rWZ7AR5s9{C0H_Pkwm4i~?uOe}qySwHEB) zxv2U$70}*&*XJH2NO1@g1HN?yFzX?G1fIV>kD@WgPW-{?(Qyx@n`VX3!qBM<0Ai+^ z^~p_9KCK2o0CiD{L!*i-UCN~k6)}%cQIjNaC?Nj$_5Ge#h(TrX!;5!U4mp<5sf5uJ z=rt&!KpI!ePx91TXC`re^H$jYuqT{oTgBjgYkyxIe}-l zQ@W%T3>D2X3sBGmCg=pc!PM1Ujix1}W;r-Xd98?}pggT7;n51a%o;&WPRSssgpzT> zy&8nK_o7~!`0Ld{d`d{|roJeXPLhk0p5BfHs~7!kavDaz8SnkD zI{K&YUZaD@I`T?aLw2z*mkJtzhA(8Uf1t(h+$yBnZO{BenxT7h7i`{V=XP`bWR7XM zBlYpfrnXs%O=klCa~x$3iiNMAYym_qg-&x(R{gca0}+WH7DpZE3ogIyFaG9i6;^Mo z7ZWG}>8@5j3?wCzk)D+EDIOV;F;*HwpqQNk8BADw3B6u<>v^Z>G0(AO>&JOlfZe=L zauI^GF-~w8Y0;$xA4eyMqHvVOr~%D?%Ei5P5r7CqX-d^->bVU7Y@%5d*`GA(QmNCZ z#`8X#h-v8xw7|ww`UB_&H>$XSy~qOE!IjbD*!4z_i>{~3D)j&Mb6EC|F5&H%^DT&|)m zZ0UIU{wk2B-pA$NJh^8P9fT1gpdwurBo$N=x%5(p)6?lAKdx^b?I|kRcmsmwWb9I;*0BB{@Ki!mny9?t(E~oY7;N)9E!0D_gbyXe5 zA+M5EhSlm1-~RFGvu{T^dO!H!==z?~#pA*h4257-3+u!NMy=R%!wh&5KuByVOncI4 zF;{~*t5JY78-V%@UWWQ8$tio{R-yx1Rz9zpvc*=g@-CF~Rx0pKSreOXO8bKLwnP1UnD_5l0#rFQPTDUG7z1>{fW@FE zD>)ft!40jf=KsFrBYE9z@9GQb6Z_u`MvkVUfS0!Jg6TWi#s;)}Azr*)t{Z^uUg&K9_TawK2`1Lf7*C|c%M zISfKm%l|o0fIqxHJ{;1wIh9dLAG$6oWpEn=R5x`qmyR@#LePa#J3ChEzxw9!`}50T zj9FefDJ5I70Lv<$lX5~$NQ&J+Nm*wDxA-5G)-j+yb1PMuBpOWAbi>dQ)!lDf<+_%^ zHhgrpWR!yo+1($_^_$aaKR|3^YE=|O9#^?1E@z~#Mvf67x^~?dq*Lp|`O{OJ^!YV) zX2}7H?f@n)<|?-@lDHSxW=^zq8?e2Cx$z$V`h>J!S!nBy;zqQMFZh-lUw&C>`xom0 z_MBt4lLOjM2;TbmJ%pJ9Z>m;T1e;xY3b0*gXMOk8u^#yq>0_>gbrrb`(G$WX4dYyd zN}(5w<<|Vgy~;EEFl(ekF6XUpwwWcZS9|HDJ4tvuUr77VPP9v^)06AGY&C)2!xn4c z{!$>9HH9I$j2YS=)d3lMGkPRlRHf?%=Rf_=*UQicE+U;0qa?}g)Xa#(T-2FR71J{1 zz>?#7;@sym+P^_XNy~QGX;CDoa>^9Edh0_ZprAjvcYJ#E*-uA4^9F!W$G1u+E$;wVnVAado#yF<7Fp&gN@|X4K+DRSSN|<*3ew6fG+#>{rPNq#*=0<% zt!Eo;gj)BIMJJ9q1h=#QwUca(H@0i$eRxf<;oi0G1vdL4&Z28i@?F*z`?)uJT5;i;YETZYwNx#V2?Wr=pB~{+7ZqbKU|D-L+V4(@fP= zOp?OHMEq^{($e^R>HKiFkgb^p#>5;b>Q00upEg!PTDGC;yUt@!Rf@Q(93B6+|M};C z{p%aI2Z3~P7Ys3|se1&Ku36qUV_s*~|LvR^zKAM+PfX0?;tbYn&d}h1;^o_OM+b_y zfAjR=7odvv*>3>4h_4^&!XSbX`tE9YdjZdIE~J7;6IwB`VZO8!E?ck6n_k|NoFrr7 zvv{(5%t%RB5sx6usUld8V_bCq_)p(mETc#-VhK_r0&zEi3QdxONge#pVse4{RQylt z%Lr_krHf3MpGNtPCB+p=7GNTlMH*Bk+XUn`0Q6}Ipp9b0X7xXTNzyV8bD@VrirQPv zyeGchj;vY6RH^EZkmP*_r@%#mV>!k^i4^M=VGO;XkE?I*>wR1H5>3TSNz!9d1n;^o zWv%1_mHvC+a1q-O|C=da_Noc(DvSo0_o5payf=E!l|IbQ|<3rC3cdYl&s|lIRj8-$96uY81l+I!aI5cNWKS(RCt-B-K{VU`PM$Uthi(7U&j% z(G>xN(&1^^pCqA^%}0jyZUn=YbGZ4rTx(WyyWsq32IK~!<&w=&3ie!avBbN}fwlm7 z)XC!qS^e{_0r)L90KMQy@$l?e5M5+|{PgNlI(U+eOlNrF(9hL?KhJ4gqq@*k|A#LS zt|92uEg=n50z`B~rly!oHfw>DqtidV@_+vBr7q5#5}m3h0? zxqDkNBwyHR?Xr7jf1PIe^?7|)HzfzikKWyG9bxVc+7&8Z>SZ$1fb2~=6_D0hBm%+y?9VTk|Mt`6D93@~pccd}4)`q|4PfA$*yBh2y5ll#KaMWXgU zzr9?=twLmi-6>1PJOuK{ts>1t?P75d(rF0&rk}ypQ&lO zl|);rXW6WcR3-(?*-sUlM3Tc`KeHZS=X5_al&ASwO`mPXs)0AFN|1y^(N*Mfq@$xi z1R;cl@PQ3~cwk3dDMtm!obRh)LV?62PskNQ!#Bs@XWM`9VV z8YBkg%zbvfWG}%=FvmCYpab}E)Ffw-ia?fCU|AfqP%?|;(z>`!hJBifB|)^}m}hg} z`fO4;*CEJxr>NWMdvjvq1W$qfu1ABnqQJkFSi`~Sr`;%gW&d~pmB-L6LlDhVIR&8Te zUO{zYFT8>_;G(@ZZkf_(Sr;_yW)F~OK-)%lDz`n0WbZ$*B~3OD+vZT2A4CG8F1ieH z2!wPLxQB%}uik!p&mJ#=VdymJSXUX%e6sa@-&X%<#{Y-R@(;&G&%Sx^wfM1$*EKd+eZ)Jhdf|EG-wanXX^-jZ5*De|GE&!%zmBbFCrkF9u+hn9xvhpTXufgVSq;w zjnqI1QGzRBm?1Gs&*5NTZx ziS$vEC_g*6tcGQOq#-P&KYAPNzx6UALH~Ch#P|nYH}OwK3`9mr+yQdv2ot zXX{Z=RT5070a>D$xnS*AOp^qVB*In|aF~(+8R^*@fkpbQQpd6!bkRO1WkaO1cpODbOXWy%ba&Xrau^~V8l0-?9!qp$j-?w(s&SLvG0)25815-XcWKTX;_Q*}PSw)2 zc|dfJ?(R%MJ+EG6W>sm5UZpqb6k3hwiwne5)-UY8Y(NHgZQ ziq$eLS<^ETm9AiKVxR%Nng=JktEfvm`+xt}KfjM~=}bg|FRUY?BY;uhWGqKWH|4*! z`p|Dy8oP>+UBk-Hkp@ta6%K`3vQBaUhQI#!7X3+xRPd`uXQw^jP}MsQz;FMd#egHj zAD%tdICf?u_2t`(2ta!gB@i=!nkJw02lfkH5(f%{CQ^Zkg>j@rN@PTbUTJjg`s1r1 zXty|D`TzN+_kVshE=Mz6C{(lmUDo@bit;+en0BVQ>`hqzWD8Ks`fA5p&1hSb8*EHg z>Ly|8AIjdK_%&t#r6yn7MwP-R46C$OkhpFpKe^2pwBRmp;I-3q+nV({9c3v8Xf{im zwH961g%DjVP=?Wyh@3NaA{;IsbiRfo#IJyfcPI1!9QbLH2aI$^Tv6Lzx0hO3V5EWvBiGa_~iac z4+uReXx}vezv&z3lyoDW-j^QH!00$%#kZGmMP+lO0&us9$`>`JnmblS>^xy34J1PInwf~h_Ts~v{Q4l*) zs&=XWEfC2Zd1WrYu;zhT<8K9SrU!S$r`@s?&?K;vqyOZT49Y;I`ZId!B4CJ;ev88{3M3K2zU^e(QxI`yZ=V<&?Op-Qot z@N^OSoKjVu_G2z}ca7x8K{Zpp1aIaHu-|z2uCl+oee^cq7i}p zKidE^Q)#Md^_x(mK!iQVXN&mNQS5L9E)}FZ1*;e?#~8&NBC5nD-7XI-n5BX0%otm# zYrEW0ZZ{2Eqb09712$wZRD1PEIIC-rC?wg)r0U;Fwvwp_n39gRnGOW&PP;mMwMw9s zmIj>!tdSKzF_}U`QyR@~&Z(`??Ak^JO<)iuqEi;9uS55r|M{cA2MP26qk+J=K?G<%qVzsfy==|q=_FJpFM;EUxuRO=iLAh zjErKSW1)EV;7q~_5a{BkmzPoC?WC1p{M0+PTc8v+F4_#pIk4Ahe_aT38YD33!dMK9 z&Inz4|8D5x(UbrBr&m9{k4r8VN2hRP=X7$Wcu<&4X#^#$%kPZZi=xv^OIzA9wVKdD zQ}r)RM=^8hsuPsSwmmHYR17R~Kqz%P%Mt(-iD~`I>e0u#g2>|;a+0!Z8pu|U=zxz* z4h=t$cJsQ{Cop#`S+W|CAx2m?1TKe2sypeSL)38f_*fsG_=yd|5rVGkNNkFNS1d4^ zslQBM*8OF~K8*6L`oYZ{!S;iBKDF}h?)H1@0X}32aJbyhMg&n z)ihDr07Ps?|zv*j8SplvN0xx7ga zL#J^UMF??$E2t#h9qLE-|MKeUpZ~ggz3k=a(JDw3@gmZi)*Kn)bRFe)>sV_4nYjtX z`g|+O=rJXGE!(Rg`03?EOx`(hcyj;vhNY^2zmVHv#8d{|r^)a?iu|KeGTHM->nnMI!-IcHN8jm;e3GKmXHT z-+q6I|MD+C$NtnsLu3+KPGqS@*tm_P+W7er*&Lgi_nR_VxDzM~>(q)!DH~-yCse2R zi4$Dbsd?RLnjn=>MOWZJm7YbAA`UtyVbIOg)4BrJ@Vy!4ZWQ>o9{)AXpFBUq&M;j> zt0h9BSb(lpBO@S2QiX_Dr!stXDrXi91C*X}JY-MxU!|5B@oer_`MhZvwBHQ%T2s%v z>i_QciMI+j)vmpf6~-Ql@D2h@8EZGY@3miP89qqE zDuT2JK7i{1uR zb&Oczxp2Qiz5dzt0|Y2821Xh{hXaG-<+~-N z>($Ix-#$G7b3dhD0D|T--vB^6b+`zWcyxx-PC7+&T#k5u1((8{^f1Ysf8nR1}8ZhfsSeF=Ge>H|-7?XZ9A)Ts()squ>aO53E6>29ACsC9@<_2qX zz5hDI!iEarwIubM&g>8O`netJ{jU1IyZy?&;w?mhwq7+i?Hsm_`LV&3+P2M`dw`od z-K-tjmM2d?z{Pgzy%gf=)7H?wr z-=8n9?9tKDJ$OJT7sFL!cT$AXbc=3G1LMJ5pg$>7v_xS^(R`kx&}H*T*Pm(^qdxN>gkdr;ol&H6TG(LX#tapj*6O z#UEZ@tn_rS)1NQm3TFe3#~4*X#-rl}#Pjr>Oq)&2Ob|IK0_Dkyv{Ig%n8=nSAPsIO z`PdWCbP9#QiMyA4jM(zht)ucF6gF&`Do+$vr~Dv;DTdk;vnH&XG!TB$;V($ z?G;q}1>nQ<@((1CHC@xKdw@f)=HFG5Kq17-Omm6@^%P|>uvfY<7)C{)z^8P^^2nLb zjo|myYu)7h=ZBtwa_BE^r4b*}C)D5DN;R(6yL+%M4bW`}anYNOmk)Y>a*QLcdPMO^ zlF-Z5csYb9>ILCb1{bMGkP^PixIGDSY$oBY=>68OoHE^~^I5iR1$Jr&wK-9Uq>lqa ze4;0_Nc~WSoo3xntuDf>T!K2(PoUPR*J{_=la);Y4plxtB2KGJ9J%Q+g*LEeojL0q zisg=oL!u}dovzDh?_>YdmHx|%%R!DKEgwxufT~RC+BHeMkqz+Pag6NuD*dG6Cm7vT zHJwet@%+`Bm`0im&mNv2px0c$eE#+S=idO3s$xkTR~=6up*Mz`{0^X&eyZM-=FP{ut_FkM#n44eiRjYq0jT?qktsVkGU{FVo#cI?aUtRuu$)()8 z7>{4PUWK5h9pR8@DhxrvLb^>V{LnN(Dds5xSMlPYu7YV%U@C?t@Bye%7*#L1l6qzG z1p}={#!?+uB~T%RG*H1#&Vd$SE|ViKW%Fr_tk*Cij^Ysjv^fU^u8bXE$v zqmz+< z3!)-wf=H*2^@}~4hgUgUEAkCWDCSaZzT>xwJ!J}8<%5X+V) zY5CVEQI;XIs+x4Y01k>t*I7@Gy?GbU|9HZ$yVcQnZaxAQrFXT<5ZnpHq@rKJ-jMEwsBlAT{-vF23FZJPDb-=(2AE2U^C~c%1+kjWCgN|E7|PF|ge&UQ zOo~*7i^N24Q>eleA|%8`BQ+9H90u#UZj{BJ-r)Io|G)qEey}5g@D+UQL>bI*yjJqv z2yD3-sMmt>jOc<=y>rux+n#~hqKeg(+B7sGIAk^F+%P(0KfIk ztfMEwwwq9aub({vgLO7K-(9YTkYR8Tm?=L?@h17WS#3{SpH68N3lWz~zCIs=9>wnX z)p=Znjv^{l$6-HTeci?qG?ZUz4Kl zkfUj>9J5DQGFz#_L8Vwt10kP8A-0b78W)1KyX^N7U_)GZhp&)y5d8EmK;*<>okA34gbU0 zGwj;_@AXkSl=F871iE%Qebg_Kw|58_1L!zgSntatjHe3);KVr6V*uEhPGXMJ`2R~j z$T!lKoqzvuX6pVBvHefXt~eh0F1AVrZFVUwavfC?xa|HHfR{5+lqU4__n<{sRmh{ecFJnF3riC?H_`K{xCk`UEM1mN}i z%ON;H6My{hEWu0?Klm{9zq(x~-Mt^g$yci&c1e_rK^G#xh zO9SCxeWCRqrLh8RphvUse|mMf@_~L8MUSrjJuCYfzu*J?&?ZnxLv?o^Y~s7}HcSd&C(=)jnUco}S8n~GZjW2Dvc z-xo5?VJ_PIVG12iacJUn5uzdbY6u}vy;E9NaPhTknD|+j#j5J= z0yuFM@bcY7gs9-i`1RvX3z;ptLxi|90{hKVo_m2S#x&HI3EVE6pZ#rf#29h874D2=)+V_`%=TXp}*tas8j z7FH;vRO!zOJ#S@_Wh%8X7plOVy+#2x^(@xLM|G5|~}cem!iP=%@9Dr+N9fUfII4bB)G5evh=|NHy@ zkN@kx`+xqY|K#=P$WZ_`pALy$i?QwjYfvn9&Rd z$v*8svIw9`=w$WeMDBO7i$f3h5LG+64y!nj11*%Y`h@|6-G~>D&{pWY%53g3|gN`()5|Q-uw7axG ze!0T^GinJEB!CVIUc3oc0bPG2;KQ?{2d9%&kQAkpy9VGlFKS_C$QBncpmTit^r0|_ zb)zi4fB9||^5tTm$p`j<7tCfKzyIqd1VE^yxgGua#}^}PbX$hSi`T0`jJ-i}#571J zFo9Ks=4>dHYv;NZEh12z}j8~_`yUX#d!@`>uIycHF`(0@cInKPROJ@y$WuU>lj*7w4 za8yYX_yBdB_Bc@-5x#^nR~YUd-BGH4sj9OA-nCsfIp1#D0~}}oeCz$#9=*{rDwi(7 z+EsJiilo~>$f&%!--jp1IJ01JP*SLhJ_H#C4-tSeNezfQ+;b2WdRk0Lj%8)`dcM-%G_9)Tq?+QK;GW1zQ>6DvQlt2W2c=_J#7^)1z*N^TUNy@17;-_}k0DKY+ zTP`;f`35k@H;?qVQ_#^Jya-r1 zsJC^HlU#wbM;In~A{1wUIL{U-IiT>s_S{;bh z=tDq+K@5%s*u~4IXL{C+J(i1(DxP?@T5V?v>S>piFR&dQbfdB5?E>O1L5ahk?XCw{ zFN4^ePgBb$bSk7*bn3n0*l;B12orP)HK9Pvth4mnI*D9Wqocb6e1kLg)_9>0dBD~y zcN3?Ftr)*DsD_BDm_pzQzSE2MTs=9_N5@!j*^7G!ggJExTm=uFV`gHTBncnSpcrgy zouxGiUZY*z_KId>%O%C50G{J{LjKGo8&cw!@`8w|=;TA6o=}niU3H>tEAqzWcgjIq z^4E0ap^#}?o_FR_Uyqgzcb=L}i|+ELYkNR+q6UC51$=j>h}+R+T>SJlJbxEoT}0Pj zOnsZ4_#qynd*H+eT9kfHJpdpgE_i#1x0fSr;n5e0rw>G8E|(LYHyMfEH2}Z$8&Hjr zLRYweaCX3Hk7o~08AfUs_2~KQWk{-a{uyAlK2UsjzxMw%4~9hAK>P0bn-#ky?9D2^ zd>f#rB3a`{r4yluC#8j16svU7BAupNBWK}FzjGO5uu9eU^%;g`Y>W-X#QuSr;& ziflHll{#GL3M$nnI0@pQaBef+hKN3S#7HZ}dDvcH)^TmIpO$7J(Dq=(BNIiYI{E!l z3Vahz2@%VYLsXZ}DazP!IFs?~dtJ|~UW33@pvgs2%KtQ}miZn4!2U5Ic3_dOEmQV9 z`L43&qfAzACu90$-5l^-eKnA_7+m29Ll_1`!eN3KgL@40NQUTBI;=Jhztfes#J;}hQNLeD`xlAta|>g6g9qZ3x* zv7~0eXV5V=0dTKBz%I?u>knxje4wzoQn??eHUL$DFAdvEI*_Emlf~tl)pH)naMDS` zq?^o)tZehqJag%W7u(zu0BBt-8_%33lR}%BT5_`~D*~8m0zM^84niqi>`#AuyLz+i z{`}MV%8k-FxK{36OB6h?%xL)lpZlanNGAgD!_OCPN5MtmUp>Bm(nB38AYB2_MKpQr z+%*8dX}Ju<=q6B5MCjfL5b(#RC(1Fp2k1|)-!CH^qGrF7an#{gT6^6(G;06$YJ7J& z1X+ac^u@d3DoCUWMID+I4$?Kk1S@QU5fw?Z^tIDI_CtLR4l z!FQ~Hn`{ACginw-fT|`U?y*ztJ6)J&ZCr!XU7%*#b)1%MEONqk$MpKs{-d9SH}cNg zf9dV=^PYXDi1TK%MR<typ`=eua@uJrdV}NN?TSi|+I#r~D3%Rx{BH%m3 zerCEfZ4YpZrn4o!m+C+1ydmR}n)sW9IP1`=q=xx%uC5r994U1TqYpMrw&@il>FzZ{ zfadFU1w3um2-83ft+9Z}q}L1;2;CIL<}w42B4mVJdjI2_@x^<8a~W4IE)qR{Ahzd= zzok=U1b%w;Ui%Yw_Tt|@Jyt^C2{fDqUX$+0-3H*dZrGq^$!3IfRdII{JUqpNV>5A> zU5x(I>xd4)nU(-m8K8dsYnJJ)tk^A{=SF+C&DffrA%u{HqMPFooJ;DTetLNo7|>sY zqaR*gqFbn1&#sHoiKbxJbsdEXGO1-~kyMRMwV_5OMP#kgpQ_TJhFE>tg{&I?Q#>`z z0Lm(_mYzo&wa_YEH=k415sOJB8^BadXd))kCFg>KjCLX;L*Y&bqBA<8(;eI(IUN3p zmnjr?s$R@u`_h3 zRija?HdH$)z`G}cvh@_YwvONI7S`AQ+qE^?eF$%HhTqNpi-L+L7cZ7=Q*t~_91=5g zcaM?u5J$q@UCfwQ2AKwfxxivkR{ves_q$r?k)nxreW~mHOWUQY<``wDImc~!w*0Eh<+HO z!q0juz?@;J=Wf1b zbTO#|RdxT3fLKI?h#AI##^V%zkq`dFyN8Zp?puVoNkbVU-(Cg^HSKl3}c89|aKYj8d49 z>*Da$j+I61{C9tNf5{rxYug*a$`tD6- z^Xsp`exbj3)2EFwx_f&K1o!{`uYWpU#Z|P60k6)(Rn!QW!hjAixJeB$>+W4e(zs&% zCvNWSkrV$ZAuZRd{{kqQG=J*GHjQFhT{bCL|McPOLL-$+W!TvnqZxmt8oZ)XmZ*#UoCj`?4I81 z!os2{6{4^t~|=eBz!Y`&d9U zxu4Rjv8+fj8ZD&+Bo=+dZNsfQ86fN$Mc+=q>ZZN!EiM7s$aia+^t0DCEdeOv30ETM z6Ga>>!Gm!j{@^G+KK7nhh5;BOonnK_#d3sn$%48`&Sb5r+kF!EMbSgrfb|DcIFI}TqxvV~R;YH7DV`23ksU&01JGE90VTCKoIhi{+U z$G8G?r2X{jG6;evwMXJ6SncTal&cYl9=ez{yqe>7t8{Pks! z#YpM;-svhdxF+X9WS^lj%Wq|Qa+#pDaI9jW4F)}h)XEi>b)jVxup?WSv8tV1M>5(n zsG506xj>ue#591ZQV+>e(Mr^=CqqgbjUrGUEb-=_FS$OQniB(L=0Y}Esh3}&5E>m< z!?+w{fKp8b#?brK{ce16j8hvGqYBfzRHznV>)IpbG$*9y&ydD&_cY0uI)7}PUW73-mfGMU=-_IY&pc`5DCjY0FzpZ zWDAul{mHE`^fj*3H4amo-*Gajp+ZdauUF-|R$X+OrsbVLX*R0Xo6v-cXo#H&R7U7|l;`iq z=kHc;&j-S08D|I1=ToT8f8|OnD**yg@#mi|qIA){LwtDJpDobMT2)EsHogcvq<-$7 zVOnduXoQs>jRIFv9e;RwY(CJP(*68yc|L4;$DcT2_@)iRuJ3H^_-g}@T7uPTtH0W__Vq{u7o_DCc?$nGc{X3KMzondYN8f22;pJt+cR8vyrvBKkCwEskPb z8zMAdV&=CVDWmgpH3SIN00_sUHekakbW}+XZ zM%gte-R-q`v*P|2Vqt!k^N=*+ESA7Z$&%y(OJ{#R?^MLH+`o%Z4BClWvB*d)N|%j7 z?C$o<^a+P!Q$LnW*4jTyp_ySRgh12*7Yf*$79JOX*mJxm{_Xw##FssXUNMG1D6M~Y zxf-0YoQ(p3=Mm7{0<5L%PO{7M>Yv!HUX2YtL*lz9yWfz||9Rdgb?Udt`qc(zlhR3@ z`BqhliejzqU1_~s7tZZfQXzA*uvDjJZP=Xo-Y_xT>XB+l!6OW z?1?T8qnMBie(sU+^KJl=w6%~9_2iv85lP(ykIwMm^hoFqxeECCEg(p!cK}rt^b4pP z_PbDReLTs!2S_{OhQh)6h_+~fLv2$_VR>j_m09mb|8|g5SJmis*6NM@o*&J(J`N#$ijz?K@%kd zS+S3j_reBAE|F9fQL9X|ZBxTTYWn@K(L<)22Lhe%Zns?l2#{^0=Zt!`wn(a~Dj>|v zl0#7})Dl=jYl!_P|bHmvZPHbWGlo3wjTadOSIn1xFtTBpo?QAXXs+FGgV1UfrcyfU&JaCk%$PQT0IE_4Xaqoo#eR zXCk*GS?65wlu|wqfK)3As3b4e8GoxOR|ZcNWzd>}VIw`sBv~Q#*+gbiKeZ*8l=J{* zff=l?wd-!CaR{zFJg>TTCT?&g=q|lsu~=d8^4;p>>`{o=!?;+7q}~uy@KFYuzwLC@ zfuCMsL>Gw8bfNy_K@wAlsie$;3Iea>ywAJ?`0P0V*_1)x<$9DX1K=v?6<@Or|gibXeR{nuykpk=9ga+OU}(#q4K(tF6}00#44 znZF*%OaK!txBAFb`X|$PcY0)9m3&};+YGXG4IqRv8Cv8t>12p;ISwvDl?Vr15so`P zKCu%Wd&CaN{+%EcG{B%Dov6W7%+1_Xp*G7M`-=7b+9Uhtm%HEx+hNBa(8S)|_Ot=Q zw#+~_mS6-topSD2t;W?VctE0wC+VPMM2)qnzuYYWww%nH-@iqxa3kH@{n(zZS8f~D z)(8Nb>>d@W2&AJdC>>O(fQgxbG{6MZ0Huj`&LbbbzJGMu0dX+ZL&|^5$ZB9_w73F$8fJcOi@Dt(QQa4qzsiwp#u&m)u#i zJZxN1=Kdyyu-mp1Nv5=wBB`1}`H}0=Bw0YeVvS@oZI?k;+W#i>XBL7$9>ApGQ<}mz zMv_tz3EKNSlKNU;#k4w@wInqLCeUi-WtJZc98N@VMXn+o9o@v)As)%{em_1uV&{Vj zr`}~crf412%CVXqx@%9Q{d2^9zk9%$dtLi+SN-2Ig0yEOx0bCC6^X?AfS;H6>3w)N zz%iMXs-o{YOdp`kB&+Ujx6M?0uLj-rS#G7c$)I!^4H&vnX+hF}TR+Tm~NZdf3Yr&FWv8?jJ}0i!8q!j>#(`OioMLiX zZ;1lplMv8M$2o6(^X|7A!c=N!9agnrh&7>9Y-)NH+VFrU&P~m|SvF?M6?yViR&|O} z{iCuDWFqd&nE(ytpi16Rg`zA17^(Gu2%?XsqGYcfVCYW&>CZo3pbsh(FEmM=_-5(- zcp4U56LW7;rf;uge!Y=vKG~X6MW#C@Wdj{ygW&yY9Gzg4IQ-qWM_oQ8bApy;wwQKW zcPc=?arGYsE>JRYjzD#>2y>$E1S6iF;pu62BqN0@wg36kTR&DOj1Z@aI0;j4Hr?qo zVTvIiwFfu=8X--ADD&LF>`psv^K-3QH4fH00mO)r2ud|KVPZ$P5}-M3Bu6?U4MJk1 zo&*2#{2kTc!Qj10I<`hAsVQi|1yoo<`0Ff;=C-(k4@wBDPy*MaBC6AO+I?ufW(9z| zl-?!%6~SZ|faYjSx(#$mO3VsVX&o8UV5q(}6&Rp&6Pm!3ouZT03r6xD*%8v(8&VLZ zv&5c^#OgAn8K~q0Fw0Mn$i`w-qzDZup+g*Cg4A}Yo{!_3pq(&H9fa(f)~pRnv;a~Sv*{zH+V89#`Ca<` z-ZC_1C-%n7@~(zqwa_KIFGY_c!U%Qf zq9YdR#*`=13ZYEC?)tIqMa<>^d*h_t2v#|GY^#tOZ~;1pT=sbXhtv3^TlK!|4errl!R=z{ zS7VGo%yWPDBs@nt-4ns8Liy0XD+j*3Lnhkn7K%1gTW@;!L0i4AjL~!On6Xf zXfdYA<$#@L9jqzoOa!ed2PeF!smzsfiB_Gn{E1v#r;CT2Q+ONt0a6Dt`)2hgPA|25 zkq2by6&CX{s<>zriJ7l~8ITisuy1|eB^S``$Ww=z180aGG=o%vn=1Bmt zbgGF!vN_L|bH3CYGM(hJ&^@uOR z!gPV~^@EdpJrJO(F5(FkM^&OD6mUUQxpM;kZHFpZls>D|aQde8eF*WmhZ2AD^+SY} z2wnB{)p#)?ILuO^PgQkh#@n@Hn2(z?aKPW2|H`(}a}xqLGtg{=A-Ud4X1ydcWt16o z9>u=<8CS8RTGq`ZJJC8TNZwv^C9(}M(B`hQ(Z!nap=|lZYUNv*yY1V<)FDjMn#OZr z@<-edER=Pr|J>256ux9m4~?#KF@G%Me{tr0Q7T%w20Cg!zZS^ap00UAwf>ko#}cT7 z7@;O=s}P0|sHy+z7}@)HsOa1E!+vgiylkGO zhfGPh@RVAB_~(tLxu&>v!I&f=5$t=-jRWC zJ@h&B3_sxW-9{4l*dNPG2*1EuQm6sM#7s;Cgm*^u)sd{8p6aO%GF)_~(UDjLs}P6i z*^Ae`jM+ES-Fg>PW?R=t)39u)%DkUWiH_vqSm*wUBv!=!X^MiD0VZe%>p4D$c+H3~ zE$yuxiOBjCBVUY7j+-?P`3YGShp3HO-IURE)^D_$p|VbI%j<=-<28eTM)qPR7PUX; z@tEi^D|;A2qtheEmB~$iMyh`D%2C ziJFKcDuqq3QB=4gB#3(B(!~u{z&gNmMtEuzdnPrZ0@2y+O3R55ye?f|yMk-B;Vs%S zYrrMDV$Dh^R`>>GjKPUj=8R4{Cf@G)$29x%zM*0vWn(a#HL=dOEJlxlD6(9Q!z!3V z(sWkGF1-Evo}G2J&@Ro!QQNDn-#Z*e(X`{W< zZKR9oN4qFbI?HY-s9F|Xpa!Ru4!ELG%`Pc&u85uac+gV)SE#Kjgn*c)t;dS|w?DrW z@0FdviGdg;?e-uVfGABME{x(75Snv6BRyDJl;(AqjuAgSf9>pmuI3<9 zspjO3@|agY;DSn=4WVmHY^6Ed>OT>injJ-&JZ)_r8dZcsCT=}#Wp28Ty}T0-r7iBU06XA_$(~AOYbCMk+};C9KIGcDQ~$j6K%qiG(zol^xYJ^~ zc^hyDvHlkA^d1?Xtp)vo&%cMhT))`F-K}8-^FGztStmln`1DjBAGw4pgA3?Dh(3&Q z7-Dol)XQ>ptH=P>Kqnj~cbk4!L<`crS^6G~}TKt+gj_ci4oOb#$v{(m-*YLgCf z9M1uEcJR}TNoNzNn)|9JfNptm*~0zRHaMeAUis%})Q7O`*^(k3@~++l2IPNE`))+g zRio(l&tDFMf<1&l0HO%#MVD#v5KKw|8Ig6ZKfp)nOMjuke};Hj+I6iUA(y% zU%rQTh=hpBvzldmqGLx z(kH8n%zrPydV#2DF7Cxed`*F;>_CzH&x=xWBZZtU%c&!;_9&Qff7g6ZfEHs?tN+3P z=YD!zm2lp6)?~vM6X{f2M;I$jK-N!j#-s?8z9;~pid(G`pY!ch(_NCy^q>|0Ecp+J z1f?6IU#^DeBGQ9`7|mBlxcv5^9s9+xIbzTZ>bOS8e~+#2)*|5WLS$=I@oOf4yE^rz z4M6)DwGDvC-NF3GYk|W_y0>l&_OSDJH4KL|=X*AUbq|n2ma3*nvw<3y5Bl-h8BcK4 zVIZAQ)jqmhE{8D!)&*~=YS^Vi-^^I2xt!0jpm_+xq|=ab|IxF1qc_r`8DOK5p!1!o zVxr-0T^CPXP@(6l%&NWI15`2oOwec)s~h&R%&UvdDB4 z)PgW8qj-GGZxLJ*1Jtdp-EStr#Maf>J27vqZf`ELM{WXd!|Ms1oy2nn^=DNcGk zxp!=g(Iv3^>x=i%5CJtUa$Z?yrUaLa^8OV*AdE2xV8D0b-8;4rzlwKaX3 zs5h0hNGqkRGjyh)@=bGRzmd;ofwyG<0ap9LwPw`+8d#sd0XYd%iuHRMh?}btA9G~xk z0sNX(`+sJ}8pB99#5uD8-sM7-mQp1XmnA?tX=}CyiLCUW9e;fa z)Tynl$e%R$?^LvGvlAhD{eVOD4XdwyllV?J^ZP%^ztB7uavszNjZSm67Y%YaU_GsdU)Pad46@nu3rdlC0z zrC3d5zB?nZ-#X%MR{!}M?okA3uVs!uK7Dj-LLzGa^8MB8E6@-fWF`>T6+-5V zi6+KjA~O{rO)mP2j4=v2s1-h|+Gm9cP_#j(`n~wES0(1GKf~#@REsfS@*2nzPFcD=4Tb{eg|m>DIb`odirc5Jgv`Ukx7I2us?d ziuj-#zdAvWVbK8`JA)*7j7Bzkufn%{IJo~>u)&S6LwA$@U%xQTvbysIpgiDul-k|x zkb`_vm%wYQ{A=C5_V4a?q5F0@j&1D_>g<2>pD7e~BI4oL!dIt$-OZUQWb5Bd0^ZmI-b((NSGcLJOVFQo8Dnogb7uOG|lqB9YP zjAAd}ksSs{bckpXk-YBdKewCHQIz~zHQbRpq93&n(nUxV3o36ef{R7MnZ%T-nnFn~ z{A;)Y5_(Fs>4Ie%z_TS$Y1xVvpD(O=1~*usi2zn3>Bewn4J(Oa5s)=FH)J;IydN{5 zXN$jVdH@AYSpDG>@TnZkoNp#`8O|!F9re?+F5CuwO*;h5K48!xa*QO*VI&o?$ME>b z@9A)41Y_UnIIb3pMF{Z_aQ`k=z>O;JgAm4Cd%x2@WXG@Hz()S=_Hk>|ise|V`|sMV zPji8D_``0>9=>)=utoxI=>hI;W^m7gc7z9u@Nh9MxH7ssodUBVaydkYIi+Aw_h`cl z8V*&-Co(^iXUkkblS*3!1_@-2{6U%h=jvZ%PlD&Jy-FM4<_|0QI!ue`n%Z*eA*-~1 z&2;T%pvYTuN}AFR$XKyG>BC3~k!55FFgFJoyu{q+s0zhhV2hv6S1ZAQ#fU|aMWhj0 zkqhLcX8J^(`<@Ovg?uMeER z5MT7qTm@)ScOr`GhLrh#ITxb}4e$Yf`|VSPRi_#vzkmMnVg+3wQv6d;s@gm6Uz;3n z&g!=QN>WFFsE@JNj-I9@aM;Mj(id;8JoYEc;9`rFVyMpr- zmXrrlZD&n}P=Sf+G@`%+wwWXjHjyU{ps3=71f*9qj6=N0QN zPD;#41<0h+Obn3nYMEyLK*F24BScoanWS&MSzCMYX;}Ll-9352M9<6a;2`w;YI(UF zqC>26NC;!c)x$o1eXl=~kshGp9y>FqYiIj0KbsMGEtl^Z2VP4C*ugMfcd{$)H0v6O zTXE-ykPYr`A5uY6MuKaK5m)5tGqsNJGV8?ANVnC9Z&3<4pc#?tqK8& z1ibcB(sQ+H3dYW_zP{hzv$4kzW00;3L~vdW<7y0%639eeO`%U!TW9)O-#70!OIB9( zg|vVT(cKxMr+d<8^Mcn9L3*vG(wih(m>T38-^uerl%t<_wjdgsGCX3a-tsAWzl^0Xr3;3t(!|^{3b8E5#E1 zW$fP%I-pNRw+yaSfl-aZfDv$2*Y{cR<2*8qGPJ-~)0z7Q%knPoz1Z@7Pg(}fy>sEtv6d>IF( z<)zjWvw6db@d;a!+|F8f0|NZAb{o7xkAD`S?jsEf-qIH;= zXStNcS=~DdAV}P=tUjjG2n3Cy8RzaNQpX_^e|Kgk&`2;emsVbwu0vWISmgSp$Ogw* zn)Yp_ZPq_-I*>|N&;oy|JwRJ6&GDvEt%8YlVbCN$%^Hv!`3n(=&cx?(ibJ6`1Rq8h zF+$uU1fBAp4o^?e`_dRH)(M*$7Q1_Z{TiT$O!{vcsdof-?(jnTr3RvRx4YYCI?Mm? z3-IvY-MSdur5)V%lTpPdcoVrs;5T zU0GyZB(81-CLUd(1mvk!V;6wgPGGVf07Sv$&!fq=Z+a2=y@Ns~wNQxZ;^g%OzWe$7 z|MO4(@{j-Z|M%AyuLDKUEo`AwheRNVi-`&t!!HzPfBh|iG}R&jQFB2Mym-w(jnNT? z(}mtY#c>alLjBAAC+5J0FDD#*5hUPQPnIErrj~_4Q$0#_f_umK+piyKT&YN)ym)gy z0x(2QmLi1{{E3$T2g)N({gJ9eRgEzUVq|cWw^yrw{qE((U{OxK|LN6o6~wv~5fhd_ zVSWUeX0&eaRVz>=7Qh5gEV2m|vxsU}DeVNMP@Pb9i=|L?(6kK`mmuMhsyLc7GI1$% z&Sx;&>q`-lbq+Afe7f`iDTnD~5zvwufTS*mNCqYjfP2xRZur58Nrl!2v1-yW0V`F@ z!a^&FZ8e4v5+)5~ppQoyo*ntqV|EgXiLXLT#4x!j$hE58Tc>(Al`q^?3hXaFK141Z zA44Jc?sj+kHRJQgCEjd(<~_f4tvkmqLhzo!!5X=!`B55UYbIHisn7y^avUBXjS??S z=ppvjrG1xD|jcNnklCnGrf%U!{yrvOj2{jX7}X%)5Aso>Rt}`3X#a zuE}9LIdgJQjhhjoNiQPhkHjG>Fu=)SzVLuOf2Z)zS_goH@~;peHueI9IOz}~%P05@RUhKV|b=R2-575NH)eBWuT2TAhiF@K|YS z+ds0xZEJ9+>rodfZPaJ$4P4-g%HT`!ME~p?fNdXz00M_X2~9F#A^;r`ryZW% zJ2H2`mLvZB!({+qSz*A_3ax@iz>m`EeW;=D4S#oDbZdmsqZk1L zp5A}pW9Yg*IENV0VW{y+kraTEDq1T29pY)JT9%Shh4!_WrI*|=xtiC{s>aQ=q@s;O zuojV_`8uQJ2T!vTU|K$G$zij3|9UdZWU|@y%?4`8%yp2;xDuCH58$4>RGGBr)~tV9 zP>@&UP)LlPURNX`Iy#rb7-H6F0o{DKXRF6Y;a)!)mnKwbW+r8jRwIsd^Wyk+1^pq= zg8h`}+bIa$-R^Fmd)F5f^v|mQsoZx@G=N(DvlQhOcWMeLlXz{% z`57gkxW1&zyxTfvnv2V^@&Tj@*==96Hu9hi6QDE_GbPYjSEV!VLERx}^Gz$ZR%Kc@ z+1?1Kib~-FAleVB@$08g#N%o;>f-c=m+xL*4z8W6I-HD1O5w32`~#x;hnFtD(D*-9 z44QMidg%e8AS!-*HH_#$MF`Ix96vg(1aV2wvglP)MApXsUq%z~`ELNWld%hNG;uUR zF;Np0^uQm#dbChwByI8X-NiB>`i!5Ltu}s@&-bQv+g=}8Zu@p59Hvf)EFAXJ&+k{R z0dV+()8jupd#q#(VHksTy-XOM8kbTJkUa%Nr)4EFUyx{gRT@}7vJ^w3-#^bHb*?Wn zRRl-^L1J#gC#_dNJHx?DDS1~S+@AdMa&$I@%7X(iOIxUzXr9)nNCKpi?qu2ncqXyc z9$*ra+pgc56&4De0IMOah7gG{0f1uc8K0iu@o_Y)OuYjPVo*sRK#BCYPRSbwXa(N1 z#K&%ZjN5e`ch&#h?e6wbuBL14{H{e1ZPg9i9ITWYE6qw1;wJlIDX54GjE|4}*_n#3 z0IFDYIAs{)Y8cbkGnxNcw5&gB4HL7Vf;-)d^{>;NZB`J0iMHrXnP_9B4@fyj+YuAz z{=Q8aIeB{(CR-ygT@{hmey{mQ6`ZV26$U=OENpw2YFWQR--l!0gq4 z=da(RI|9_euOFW*1lIIs*-1%M)f$vl=AwTYOMovG0yJH_8T(B--NcQ2_D~+)I}8jv*3CTk@lCzHj(e<}+)=yrt}V5a~!WxlRG zphC?bb77C@EGTP+164R_)onLMfzauI!+VejaYt!5b3dk%Wm4OxlP?4V z^o2D5RopJm4Nydji_n-et`>%8504fKMyJ@He|R1hp4LPy!y{c#zsefm7R2&HEvP_;bb5?#5kqDSc{W-5$VM>0G-#+ilQ2LrQh{*+@<0!<`7aY_V|UQLR$>Bm*@$HY5L^Qj5+KQ9Mo`aE+_ zf>x$%>&L{YAE@xl!ab033APLE$-J^0I;~1Rtyx$yEp1a|GbUS0XSt~nLP>EgGTC|7 zEs4mNbp2H&>3O<{Q$qCBLh$(H_tx6M zm#qjn*)!yu11raOKfRLfIMPl0tH)=@2K8~RuuqD~A;vE?_lEwT95AC@bJbm|}!Gz{*H=u&I_QD-)rb$la#w!UK9<{PzTinB`` zT10~jq*H2i{CqY3<;^=^bgHhacmL@hzD2weM0bivX96FHn95QiVzL7?p^8up+CdGX ziiATEX?RX#8`rA7zRV;JSjbP*R;Wi>2LNPMi5$>e$X2|$p;b|4hQUP<2${Q~`;L4a z&ByF`C=Pf-FxG@2Gl7Asn;fDl`FLKu4&Et46a?M7j3qtq%0wKm16+IAMYtyCY$QjAQQh^%&CGb?(bCDs|{Kb5RWl2d0EHDcx|DuV?# z&wo>sI9w1J1ynJW1<=C) zlHF{K4cXS3s7rKZz@TVUx&_5VPF3j#N@V8$>0((uT zfi~4YomA6y)igUi%Szx(!a9IpTbcF*5jMqmg~l`)JF z&`$eKJ^X5NPai72zU#qVw}=stF2Mfvhv#;7wj2h;@sH0Q-tT3h1R_9p?@d*vA|83- zUX042)g-*=Bh^34DzMPH`L^Pr?RdT^-?2dnD6uu^erlT_fyp2qbGv}{$qRFP!r*Y% zxdA}hErG1pQ;EYMpui0@p(#~p74DiH=_L>ji9~QX8cEP;nW#k@G<{1bIyzUQM~7$! z>qvIKI@R&X2^P4LI4n#(L=mNmmw{3ebG9*a;N8 z76-VGVpMbzwR)XQL|3SwV>rR;(IVdKql7E#bc}Rpgj|gwIK;Z>G3`i+!Z6F&|KzSH zF;UqlA?YtWBg=+LGx4`mqffrEUP-B0 zFtm7eO*5<+hPF=4wC?7(CXtsMgyCRN@7I zQhgKVkjIwWBn=H%GQn%cc*P4dBTb6~aYbuK7>KLUSAi0^peS8@JjVF=NFE+rk7$`-mvhVH#g-Fq^0}W>y4McK zej31mUPZJvcr)(o@s>iv>n7kZF z0aQ-)e58_Pd>UDLO!}L(D@fxICgHa>?q+E-wBG4cw>E9STXJv{*?CAIyR)3#wG~Jo zO`6ZNJ&a20#6DTa=LR6z0;D^VNe!aGg-otB8cW9!fU7ZFj&Y1onnag4biRDh`Lok* z!EqsO3iqg;m6Zsx5llt^*t&Myj_h*Cd2)yZ;Vpvuo&xh+Oi8I zu~I-+!NTyzuOI#GH&6cEx6l5=-~I7OVZfqCj3NJ4B~PO6-N?I-;unbUH?sG-k&4%? zJB>ZM0_)W(%xKk1F(KZSFi_~4!7aY9c>f6Z7u0dJV1lC{P|jE5`LIG~2|p8@(G%oM z0ZoR%krI;IS0(`$9R*}$PgVZO^fT3)hp%<6Z41z9;H>Lfc3V@AR)x@ZMU$OO%IbE? zINWSGs=RgX+t;|rQgd+D=?3#OPBSzU3Q0IVz!Uq7N!4SB_ZP>+7-c0kJiOOGxp&-0 z_dvCy?|*)E8Q^ME3q=h77drEQop=bR0MVgc-pMZmzI*;AN~dZPR^L3n*DFjBBSgF3 zTY&nVH2@Hy^`=59@y$~?SzwHTy0{$V^?Num4y?KXzi9Ds5R3QV$LwAc3#@{?qj+=f}g^HMO#d$#-*N>|2a-k9IK%Wz#0VpvJkY=W>fRq8 zpML!S3xRlri`430s*ieuX}CMcH*0`@gZO}T~%G-Tiy>ZiI)3};>_mtd9rpTs_4564q`8`O78?VBU#Qmq zrTc`1b>bf4m=;na@x#k_If|nb!AZwIJnc2YBq_`a?=}EmctlRHb{~7eX^*cS-4l-v z1nvL&{K5q;qG}$-nKaFBJx1ERP7b?rglePXFF(DE+B?B8e*NU(*+SLF5}-v$K|D5| z^lD||fJrcy)p>-dLvgN`m<RIf<3ApIsxEej7|DgsbJRZsT z=*S-*qw}j?qIeY0x-PnlY7w+%kyk1Nzw3&A+yp;%(}ln1F-5?+);7&fyzDH}{|Xt=PjZ9RKb9-2M%~)-aMZ zi8Vzbg-hB_W*kd1%8AP12;;*eJUOvGE)mCUst60P_gBN1YcG;;YH$gWF?w_)VpWQ( zbKny+ZDoN(jn%T16%ibmF}FI2Zs~$1OurP@txc7)EvEI;v6>QMZYsbQ^k2Dcy5R{> z`hh8RPAaVViHXD!OVl~2R+mg+){78P;9hTM9g>b8eN+xl?;SsTa3UUJjH{^MJ%7Cl zLhU1%sLh9*MW1XB5JLdanS|&t48#vF-w*5}DGc8{zW?|X3x(2E!IoEe*8qHxH-aI! zi70&m{Nd@z>B0mYs4p*8FW!S)nsGDLbD!wD*m2>l{}U1+&tJcPe>FO+Q#msEo39_6 z55lN|r2hj(^>C!|@x5aq0j0G1SySdu+}y$~YQkA2Kb7yWnbQ{#q)d=VZqtZlLmoq8 zB{rM=SNq#p6Oy7_nb7P?x;tiCy2%yW6OvTbR8$l6Rdntf;4BatZ|EdP zx67c*fn!YCpb|k9f7r)o$2gXu$Jhx?Coxtn#lB)r=@Q*j1ngHJxwhcmTJL>06ydI> z|5c(~#@i8Is`%W<6GFO)BLLTv{j-CdHa)ly~R;UcbJ;EpIH3%$=T_VsDj$P zy%=AaYyu^a$WlAt^xQ$ z-*6{{FZw z;G{#RPDTo{mx{YEEHpek>y@7T!(7BG50DdYw$Vd-B zPHj$s1n*YCRsl>FgLH80WVqkOM@N*n>C`amNAv?AnPv z^Ly;z3UB@X&K{uD5t-dpHEbZNmcWaq+N*Yq=F3wX9v<^ZBf}DOK*v#{_sbE3XPHii zL`j-+h|b_>Jii((oihH1KP^y3kX^4;S_r|43a`iNEGAtjR z^^flzbrMLb_0M0v83MX~kr+Xrb!6@r3zFkFs>#an{mY9{I;i!=hiCnxGa!H=iqm6M z``ty1-&q473?9T}9OJ@{IIws8hrfB)#nl+bsP^)5c<~;h2%|^$UvwRGyT-bO18rIa zNYPMB;>iS_zkYqbTzUxV@d)AXzIxPgPzI+HD%#OSp-Sg)@ehCVRU9rCedi!mVO=1^ zI*51~>I-2aCMlp);f2Q4TuXE=f@qDE30g~Yol66?@3NKluls=-+$Wd537^R-Qn5Mz z-rSR{9j+~j0h8uYeNI(P(i3SCqbi+gXIhRxQ3Z8IXNW#}92rNCqca4$ldj_^=jUN` z5$i&9Fc?<%7ykAAzV~IPo-7rD)1hh$fp*d*V`ddK)ifibIK;8n^mqS+ZpgAv2eHdSHmiLbjIjojKO`39-U)kRRK!2U#ap=upd(~Q%io8%C$FAxx#xUL7Qef zYlm(p<_goOeo9H(V;8mjrS|XUg*mf07v`L|OzM?t@mu!-n7L|-i%^VEmts92s==d} zMTdxr8tIEp)K`D|?L+k|bC)O{QxbFG@OR%nIa?^lm8x7^UA=hqD#mEqRd&SYlB6wQ zcDWL5I6D8@;bS?htna$64-v24)>jtYd_kknwH<@I^=4#MTrP z!7TK^{T|;uK0E3qf`iz1FD{0NZqZY0+|dDlQZt_Yx9A>YSg3Sy_~X+DM;wnk=?rR` zi~$o+K*5E^vjy%QiG-yJ(~iJlP7$>V$H}`_RG-Bu@HLLVtuq%|%doOQz^0u4e26l! zZ`)}~Z4c(F09FV2U0+OMh;!jjS(|llYpdoLj8qF@6-aiVlr`7MqGOFH5=ElZA%v|4 zzgp61YTara`c9SOi7cO-qVuaxf(TteRixZ0mg@d&!jSj5Iq$TN$XiVSZgGWdodVwa zB>b#Qn!xyd`wip&e#pwT?ZJ&M z(*1Hkc`lPA3R01;R&j}{nlPhjL7rEv z(}fOK?^PUhkBq@@H?`cOD*LNOLqXRsR--%Bf#+{li3uq_e)Z_|#9#uU!y#f>%Hys9 z_|hXN%H<3JjX3M^$7lD=ht6cAynJ`{@*PIP`b4t-WlQ%sq0A0*-i>c zHL=Wc6(`-Y`EUx8w^xcr(((!lajK2H8EUC{0lSV%*fqS_ee|ve|834QG-rq;K!Mr= zaBUH=b-4S1N}ZeQm%F+CFQ4;jQx7)RjkM;a`7BW2S$7iP^;j#lU27^HD zVihh1AEJa5IjO-jh^>7M>A5QZsz}LU4KMn^XDZkHnA>T~bhSz5*~+BDQ`x=k^Av0k z*ATN)4=_ni*IK{v7AV%jn3!P`rYK#PO#N2=8!AJLOv5_oLb>p(uOA$pEX2%{>q3?c zNa=rE{o%>O$ES8|p_B39gR|ax(_RZW_CUVxjaI(Dd^!-QxabJHU*hGvt5xuY=>p+z zzIt%d!O|P@vJ3kC=^XstjKC5Plb!@w1oYSgPtNf8Y@r?i39#>fK3_RTLc3qq7~-Q` z0QAKbR7Twn+Hd{G9OG+ z!Ea3BKU29c!V-V9LG~wvdL7Rv1V2$_4GN~ed7g>s%ZAgthT}}-Njv-Jmf8RAG5^NY ze|?tI$USl6eAzZ9%aG{F@+>E}mJsYD6A*}FU?K!B120Dp1O*|CN1brExZlUG&-4VV zPV)Q)sji<8QmBr^sWC{TE|pBZ+oS)3!(lwU=FB{NmUAs7@$UA^?*SUkElfo{8xV21 zbcFdKN%lwbJxQ{FrJcy%zf%JGG<$~qbpKs|z{C55ok004KA)6-v@VLWUR?1jPgGTa zjNzzb7tf!b%7cY>zU&a;9{vB%-k*QTaUNNs;5D<)Jt9{CiH!gkky1%2)z;hV>HmGs z{5o^`oHy@Hb#<#trH$eWfFRafBHaBmyYs{69^nxgxc~$usu+IqkcrI5%@<4W~^l@jP+Gk-~r;P?#;8ND7W>0?~|FN=R! zwWuG7IFH{3+0dn|+~O2-`K=b^KF8l=^|On-CU6!GvkBC|#Jq#~hj$OGm!jIDpesj0Fy^q9x zfREkw#gldctQ(S> z9gV5xgQ-|_l@=nqFpA72fkLt*7>V73o6`ep%x9^nx`_aA_X7Lt;6W>tananDp~BVh z)ragg_3#CIWfR%7^a;SK=hrCCa>4r_M_yei;<>2lSBv|NZ|6~;a{i_K7k#yl^SU_w z)M5cdVd)XJ(C74!?8ubHdD`SQ=q8z&kyCWL(%jjV{gl;b$plO4=a2xjoJSUxYlyO# zZQrmAHZ1p-x7wS_3C zh$^GjZYs0)4y)QvlV*=Ly9ZJedfG;t5tJNdZGDAkUPiB`Y6~meRpKQG1i|t?FI34J zkx<_#Bg@6z(Ryh$T0t9seiK)-CpBU8+YO39l(n5~9dNP!(N%?lA{;E47XXy1s0m;~ z$+P{`-oIH33%iOv<)|b%fiP-i70unliTNx=+s@~v)%Q{Q|G7H>!OI3;zc|jYDkZjO zAKbk$QNSH^5h*mY-dT=Y8PI>%ao8l0g~LuN2!f%3JNvkMuw(9kwx~Y;rbWV^pY^(t z!E*h^VBMFSwCGZ_JBjdCki$Dw%h?B}N(8;A`KU-j9Ms9|8PXx$uS8#rT z_*@ULFp)1dG!QGA{mYF2Bg|c!jqRh2+2dO>U z1>-E>PC_h)%v*0ec3xD}h!sZuc#&*D7A^Y9(Ff{lm#TcrQ_YQYfb~F%C8Rv|4Okat z8u?nTzo1X4J#k1^{0FcA-TEv;Ey$*_AVzfh;$$epqPcfCxiOVg63vpF*7&y*62<6#Yj=9@CJZTB+L?ECjK6+K)qkD%_RTXuszIyiZbcR1eLGs=ADK827CNR(HO+t=5GZP9$ zRK=vYwIfhQo1#57!}jC%ZtYf3W&s(21xaQZ?Um3XDzeaJ8?OWat9!s>NbzFL%GI3# zxh5Q?*Qj?dK8?h)(gQHS(6+<8@y_YOMsgNcV9)gS zME126WKjWVqDeFnhN8(%8O(rVSPtf5wT_TWX+Kwreqg~R?6&Ox0ag6xquKR0fZy~7Z8W<*-zxM0u}DW3pK}yV z9u}`!EafOwpe6>e)|g^;dtZ||#Rx?OdK(Yt}br$uaCT7QI-g04?G-C@Q7;U-0x8;lV%;C&g~1AHH`q(X73d z!V0)aMyk}5Z0GxvDtS(bE;j`>82>U?-fvgFVC}^h-JfsteJ%-w44jL3p!iDxHFDJyefUng@xi=o zwe%wFd59^St$#6skS-d(6gFs&ofJMrkvf&7GTg$oSBkHtNSD#HaHs5F28+tU~2qh@KCkdy>FSjqHqtmkLScoPNB&0_zXPR|<_6Wq20e~Az`e*Tt^ zS&;+MA?>?pqjF|A6=_uiGQQv(dgC&G>LthpTO)wK^i_Rx`|T2!bh*F%7UQ^$z{;TX zV*@R}UlS>E#bcjxE~Md9Awfp6D!RODm`ZcBixg)GIXrtHV2z{A0E|U@TB*Io)xzkv z7r*DAd9}P@3u|aKGo8139RFjJzXj#O1FLg@g?;bhLap;lM}{^fEpxmfu-Z;=bMU;d zE-j*;qbYl@0U)B4hzV&DHM-s84{q(4&m^2g3<9aWbah0|89iJ37#2u3J;ZA+~5aKW} zsY&Tg3ldN8Uvw|FP^rqT6BO|vC0w= z@#t;&lx1R+2@ipnXW<-E_PosS+Giu;JfeJFxV>kk1KA>L00yQq%y0=;X3r&1CKG~0 zq)IbAM00Tiiqh?^;PK?SB0|em_s)Qf!SMA4lZ%o-*G@_AJvQ_hzfK{pp&%zKW zB#A6NS%z?+vc}aR8SF>2{ldVYbG$Sa?)5Zv#dC4Y;TdmGjbke>7P>w!@T|JntS$Me z>_T8Ga+W*5-^*N#6DQUPX7M=g#&c^kO|MCQR^zY~lh^dOFai#4H=3sTEm?LGOQqTY`=o zfMq@_*KwTNHr?36P_@6ex5uA5#Bs5!E2KmSL+A_#%} zKhB*ZBGdpy42!=2EnowQXj!>zkMD-!zttwd@);cS8y6AsqC``mO^(K!Ybgec%8p}s z%H_+};=>x^5CJK>ND3s^lvP6ohDuSXB#4yzN<~RJI#`I?)b^swQ{;zxxLY-tzc9&! zWJwM5wDX)rVIG}oBoh-RQInz$$DmUp)G58?O~FP=VM|D$+{wdaps!6AT+j9FK#XG@ zO2X=2Gk#mP2MhY;AVaj2?H_i-h9p0_rb`dwfjcU))R0)2ey=$Y_dC!fZV%i2LBZn1^bpO**K+2iXYpPgZ@_Qv@@ulX>nK^M#ByP)K%LJ5zqZ5cr_XD_PE{cx*q zlw}PlT*w`iZ#t>8o4N2d}o-14m**HY&-lfx;=pYTx_6R zhL&!R?;ZZ%Aa5{g#fd8%Z8u-FW!U63!oud(r6|8Grb{uTS4!{J)lY91G;6C;kZKhJ zCR!^~K6~%x^u}(=&CD!ihf_3FC#UnEs2PRDezD)J>Sd;fj%X1ADd+iW<6W`whVA04 ztZswh!c)4gfx86nhjrrR_ZZsbU6fp8*Vq^PPQD&!kA zZ|~|v3v#pw@my>eFO)@>TEGjFH?^|lO%KnGpl3JHCEH;+u;=Qc(f9j2U<}ulsV0#s z$=Qnm1!1oE{M%V)leX<@#eOY!Zh##s?OS7xB?j?h#5aC)OTbDI>f-i~lI{&9fKBGf-0i#f4CMjRAy2Xhf06@1Glf|EjiQYu`mrYsTd++ zrYUKvtiq@zza*Nkh7}Q*0S8mOe|Ilw(DRNZ$pO*MBNxfsh3z9^wf9K>E+U%x{a)3-0CWNa!^+$C3}Cdo<0mI4091QQ?j^+B?ePxh0N?jW;aZcxbHQPwGx7MASS^c} zmWazQkwFx+U{A8puU8SHYf6*QK%$D!qnc<{LvmM(G_yuTlStRLRGoFXnA}3FH=G~S z-ziJL{o3W*u*4;Aae>aYRU1;zMSRpXn}UndpDv_RH-*;oS0SkiC>o+cAOaDEB^ts` z;PCeT;m%HSS1iY4`Xkgi_>nCE_k+R`FN*nP zQM%9wQNjYZ4{-ZnQkw(O2EKYS>xjlftcVyO0&1Yf;Hjy?h0yW$+xrS(Ar>ghyFKNS zs#H>)0n92&Paobh?8E70}B1A2)&JPsde;1 zRy_e0k?SHvYW&}emVnPCr}KZ-8%-6jos36IAmm6DbYPv9;bfnNL@WiY?bvoys{;f= z0r8xeo72}%o;-0tEd?nefuKNoiq^n%9SqeN4E-2-qpw-4q|(-mlr1&{-JF*Y5!otjS5|lA#`?Gf{2e+7G^g0*EeG5!cNh-&KU~ zImZ1v{o=u5CqaasBcXuc9idIvKq3oMo-{L1o&$Tv(lYDzmcgB1l|{JPp!~dOZk8uY7mT11xO5eG zdsFT3YZeN!IbF$>x9+yA|DhF*i*i2#5lAVOt{|nfm=Bb$fLof+pXG|YtM>Sf=6KkE zp{(P|xKt^4{IbCWz25>}VE-6XXZk}`w65#6ZLL(TRpnYe{`UCWXRp0GvpB2R8asij z0;)l2+K8f#CPG9Z+%uvifUzVs4Q}#fRIcHhd2$P%0d|pjk5 zI<2IhhKK}Hu?~fVh%W%uKZ{fNLKv@{h=2t8UqHd|Y$_p2!;4cqd3LHYH5%LbM-PrB z1`{x%7jD5~CZj_AadI1fbWXravwJBDEdbC!FTee24*@fXqXKRp;MV?x-Viv;^2Il& zVdw}^pr}U;9VD05dw(`|Y`OJ?Bi)V=va;ijUp+th^4sHCrcI`fUJUr!@IL@isEKH_ z6=xsb*_(1+s|*DAyqWiv|Ll;EEOZ-*?hkoqOd9 z0@SaAp{F=5R+)MJ%kiyaMQ^GIbiwP_%`y9(giCME?eW8$04#9LayP5$`4*RC zO19nJ{=uUD8!OlAY*k*rNx0+-F9{lj;=f=j>gde|RiN_Xl#-}vRnNk{eERCiwE%>1zNv zKRmgZ^}3qEmI-mZQ#dBLF4Lb5ujj%JID*kpoF-I*Fx(79o%K$14Zsrnw6BH^)A~^?4vf|69dF}?7 zk_Xf3y*tSQh79^@cQtH0{3s9o(FN1xT{K#U?fuNb91}$%9^SpRTOlGxs-B%RPhP;G zL2#HjjqJ?_#9O|1zsv$PyD&te7rjELyK)Bn>FJ90oX2@G}S1tcx_t7WxJ{N)&6Pppmh;YzV36w%OK{DmTI{ z*Q|#wQYa%AJW(uO#F1R7EMzl80_1pJ+&j~2lVjGoLsFGg1zvo`K=u>kg!&! zpS_%Y{(LsiWvv&cSa{}GZ~P@XhE_(4TT2_v?*{f??j5$V4Jh)hn|znA1TWNBI_+pGQIEjdW18eMIb=PsFfplQ9yUeFfEGB^)i@aWxLncIS@~M+kw+_AVgx%_Mgjmj zN%8jnq21XDjk&-Dv2i{3vRep%;fu%L{_)YX-+cMaZ@+r-s?BcVh!8~I0FW|>gvwk!V5@9MqgyP%Rc>LCg{K0#-YoJ1iXDh&m3~kJhQ~&=*+W>I1L_X9q zJ+q|EFjHpMf}4AC>tIKbLnLeZ^2y6EczKi~bRo#T)cyTVBY^AY-RPC)3l&I;4?q?? zJ)VE@?aLXf2I2Ph~(_sw(T!o*}n-) z`wn32^FQS}L1WDOFQqIYg2i%fD5!%NP#>->wSkbyH!n}We*W667Om(pvH0-*jR!aE z-oEZy_U3GNGRfH?)@HU{I0dSObvbC)p%m zZG)57D5(vu$@|H`~ zdq-vjbMEzvWYyE;ym`vD0r>Y?{r3{r6z$h%3$=nls3Hgv$t@;^kKQ{pv^h7l^zDnY z=f`k?lV(bfvP>*M83>e>;&_uG*d>cHqe(r-vdsi?P%H(Dj~zwfU{?M8=U>a@pb3>~ zvUhl3N!>FdWRv>Md9KLfBRO|fok*c)^VtV?_omt!8Zg3PmONsfvx`C!sf)*E9jfO@ zYi*&dyr#O=a|f4tpgIsD2km4^FSR4*Ocs_e8)?>W{xdH*)e$k)h3LW2)#1P(2Hh*7 zF5WyMqNn^fZD&?RAUo$xdp4UBLO~9%6DBdcyNBERk^J?02h}I{j*{f6PLR|^`qMYB zKl}DL(mrJp6tkgqi%y#kpZ>sZT*|od(jquCu z@y;gzK);{jUevy94J3At(kg6E*PpouxXx0|bw#2t){(?j(m>} zdw``laa3l7m_df9n5CdN605z>pB{hy&8wCos*_ahsJwso@b11%efHi#eY4JcsY7H? z+*wtV*}R>%nPQzs033l2^Oczxnu9C16I<3Q_ENbd&d6aO(r`qv!z^4|6HvNg#kgZ?FLs zBw(+`y<7WrLe5>9?tk{>%Pc^$9MsZB_R^n2fh$X0_P2v=u6{P8&?2KsDd$i@FsSL9 zXRkY^&Q;W^DjnUrVS-A{M!GTAth%Zy<(yTmvPz?^*nDtjH^rHPs#-SvozhV#wlpzV za^WGX)c@u4HL^UARZxGV{yB~yn`Dw#sFPfY4SWNPZAJTeEn00p?~6+#qq)bTVSrlI z=8#l5W!`k&n~R`M7TwuF)!v%q9cgQ{7W13C_N$+~KV_%hnI+VFUp+hhw3swzco}F@ zOf{v{WzWue({(wlstN6!Lsh&&2_5V*FN30a4rP1O0^*_%4I)41x7WB1*)LN_4{MFL zZWpFHt1e)Du~FE{eLqnCZ7GKp?Jhw;p~{1r_Ydk_bBz{=E@xG>k?PkqZ;7P1fA98m zQh6}ddrx1UJwI+bSQO$>ggT8$kw{)YsJ~_Wm!UmcQGTKTDOK6&L`!`B_49gXugjex z-@CoLKS3ohflxrxzDtxPt9Dr21LDUv1o-ha0E^KT2OEw4!=bXmcrGNOcJ7~ueBfCnI5E8kpT#^37u%2{kCf1@SLOgZ}@Rst^O}~&~&o|@+!CT zqQ8hMEcJ{2@C8s2mwA70oaXdYpJENXFgA0NVwsX*v)1RG6A}>$RPZ|Hw|4T)9iM=; z0MhPGcz;L#{^8v{%}K){r>@_;?mmBd+_IugI%+lDoi}YYnG7s#jyok;T7c;#elZv6 zN#kOL>FbB=H+CP}9@}Gkyz8p{O-X>~U$ar2=1Qbs#g=59iixi4c6WE(LsQBjVv`JO zB7OPI@zdAsNuULjV9(;0AK$#Q9|xvYrsQ_V{IKfo?5X&fii;p3M6JtlGMl%V9Ralg zuH)DU87N!8j4|+kF~s(kL@92Ni(fr!OW*npFw*(6%F8}GPM=HX<{_U_1Y&JQ&Y~Qk zc;tPZ-`zzmt^!NuL4nXYHXhw5@3wL?fCe5N?A*Pvo7j2hsykmkdf7Or3c50kP*;tz z>E;`E4Bk$c?~=4!NK}JQ4?(C(h=k#r7kqItpLaP4Ys>H7J=mK-0mS0c4QHFnw)IC# zQT>rofaIcQnV^$FMi3A%kbt8-xwTiTw-F)r^s~p$PV;h`=E86Ky*VzBMDk^%dz;Trk;R5(C)&Ekj^unFsH9||z`|nlAN-ID(#F1uA zK5M!H!bF6^r9HV(^Y)%j&}w88F%4l;<=LH`^sA2_){=p+y3SHPes=uT(^vCII@vpG zsr57m1JL4aI1ocGPvN*q1?Vke@!M7Z+fHD6Y>#({iC$ElU;edM>H=N<*~?o3^pnmp z0R|#sW?h#hr7i?%+R&of`~1oCFP}UQsl^f_?lY2L+ z{ghM8865>uRF#`NYqL`+7UzhXpo|Ei7wJUHM&SRA767hYFmjce+9Wz0@XoP zZ~ZFhN>1!QrP39*1eE!_3$n!Q?tvcG+29M_69y@;>+3WKU_y57Cl7C1ZYmK$I+?|j z=Z#AW5mQZ~MTyf?Ao13TD3Fkf>DceD)`CWTT}R?POZnwS^nx7TA&*& zg!{Q)PyKl}t-vKuh>Dm1#((TEZ&{-+y;)L! zx1|ZLO=aFT2HRtMe8=bFO23Y4?%!X;@VRCe(Yq7^I#?GjzJsPj)y^T+PK%Un`p2)I zK7BoNt(;8YuHlzIdGF>_EH^3K#FNQnG7XP9#RRhl2Re;YjV{P8A^;C*Iyx;6eC@0qr9=+j3@0fuboFeF4Fp!3fu+Fy2m-kV7{k?Y3_#YD zB4iUvgpmZ$+Da>8_{9YTqj#4P4a!-Y+f1>_DIQc6jqc74j_Rnw4Wf!*F2dMmCX8C+ z=3e#lpWNTAqDt_HdFZ2O$Dcj^wh8T^T2FJYf2T#nMSIQ_q+h|l(5Dj@eD;!eF31g6 z7=gXf9lovpx5xJQ?iJD-J#p8p$=+fYNEVVcD1ya36P^U2R!=ju)9Mdjee?L`>C7cm zO|h5s?>_nP=8jhADuJN5)DbE~s6u^yXTm!ZYIMV#AqBN*z3n0>4)k^`j409!>&vFX zB~mj8IB@fp@b$+gS6;itdYj<9Ip+DZCq)aWZ=XhTG*?{(F1~5b8AT^UM8OK}(VpGd z5%C%5r8TpG`E$AD^TvuJDcH<@`u^Qo-4q?=q>ZO9PdiwMW`500bZ^Dll?Ne0XoCPLQNo(yLj1@;rAe4S@Iar}D60zI^m%Y+l3MWF;zq)i3;7r+q=6}6#kDA z26kOnlmxH)tFMLBRfPV!F!P&V@oR?iORR;@LrNu|^n_CoK-2kb-e%Gl_Q2$Bt#@~^ zk5(NjRZ)|9+dj-dlsJ6b9 z_2QM){{^R9uJdU9c;%G0$M*Pv^$(X5uiu)gw~7`mq7avQn?1SO2yHs=sDJp=S5Kb5 zX0?~Kg30dG{{E8>jwWW&nlPn_NbOV?Y4c1hazs+>#o7HGnHV8dwPM?uooDl=%TbEI zWhJ;C>aV8ye7|+-CawAnXMW+Du&ExndM^ve?h(}9mB#>1kRRUOmzX13MCh<3)+ehs zz2btL+llE!@&0>vRGca<_RWjqS7*6{h9Qf)I+8lBIKSWd{EH3^Ko2_(Lx7HW`U0(#U{@R$n~+cJ2s4lr>;kdWvoI0lUaWd<;`x^O>hWOuBGXDGMGypEaK5Z6vd) z#>WqCskce{KSxc8z^29Ai=sb)Kny#sw3Nt55bc_K2h~B9O`H*6h-eqgF{Tu?Hz^9IFf$Pi7NdI#X!MHtu*N43kM<@M^DxQKS7+UC|M>M;tJU;I4vq5D zb+CUVREiMnQoKxhf|ahKBx9+5xppJ92K=9Mquc`dKggIi+z-7|-C!fM6aZgnr$}K8 zONInx*^hH6l_nc+h3)afy!02>nwu}+`DoCiq!NSdj7T3(3dFzg9>K!vD};jv?4(Ipxt6K7;HdQS$so_+$vfXzj1UdS{8Opj&=yFXw zyuC9~hj<%&dFC(AJc}TuE_VP^O5xn3!SPOS@?IvXp!G@`p#jV?K6~`7i#Y?{zISx6 zUtyx4_w`JKFDS)WYaD;XA?`=00#qI_0aOX=7eNuCp%zdfl8TlAfw1#-r^bi3?a)-h zHKh*q=a1&up(Z&BcU&MUO2E&9pqFzq1}gb==8^>1g|n(!v6pi^dUn=&RY_HK?I(9{ z>_*;EAc95cmJWB7b!Fo7M4)BBQouw^R82MI2(zRvQ=n>JKD=46Ju!u))J3Gp&OApK zglT|LD2WWknAGiC8b=G7U2@{PjSKi#IPVeuvibjl< zLf}FbRP2@hX-X9sP=R&iqbdL4XYW;MCRIyQm+6ZW`NQY0UcL^i4>Cz*NQ5##Kus$W zR8ph--3l`#K_V0H0jR zK-eDdHn@f6(9C5+C=`aU^$gRpB$*u|P*B))`NS$Z;6S*Ts>8E*4502pX?uGK|7PnMQ z6y{ogJM;hf#W#=6JlFfJqaqL0|I<$&9Hp+}yh?@4DPj`Rn?WknU=?5>RYd0Z_fGFr zuWHNzgsX?S)~9W}Jey@mXzHke1TjK_;So8)8AY36sS3LA4_?iuSTQge%UX=;!qJXd zd2Y&AFHP=yPASJN#YMp1N=6&4rLmN@;$$v)k|Bk#pcE-QeljA|G(Y~?3?JRUA#7=> z!Q`_iug^L_OhP7FvkUDh6motQ`%T9Jo81E>TFy{K_E0gElwBbjfa5O*=S5TL1AsJIGJKtmDAC|ZzQg+{gwz`tvlWO(QnLjJw1<47WE05u5Iwb8j- z;r)BJ*v;wDX8HEztaZ#=)Kj!wK*Zf69B*9cT(~o^@yQG#Rg=~kNnf1hvyPS~8u@T) zxA!JHMhO@6J--lXQ^$Xe6jc7RP$(89ca1luxG@!DlXC}69;9dvI#C$vrMnb?e8s)BEQIZ`F$>@U(W-N{IocdSA=CrG-oTNvaFKm>0JcZz1cV*vRK=Ymy?wZ248qPj zeEy{EfSge!?U$d#$TvXBE@_T0TE-GUU^(mSIsW+N;}GfEvz-+8kMm zMpaeU0hnGiLseBLqBlbR+s}Xg-ocJ>uI4lEUPJ%)*UvtGG4E_YXEkVDS=Y@~MZ^@9 zMgo*7p&ptcS&QLW2daM~2bu5k%I)#i@k(e)>xqe6x+3K+K$<8V z3ed5?laS|V&JOA{QD_lW$pIpA^h~$`2jJ!b&-VC%&JeEu*;PuqYE;6Cun-CHkSxf` zY~jYxvhYR)4Nyhu&ho)zpIzsM$X8FEbpkh)WL@@NL;ZWahXt+*qteAwf(JtI<>T2|>kw7% zZcOdL9ZOR5p|G7?ZX1B_b{|5Mr~*4RKD>Wu>>!$9Up{^QYK|^YGKjDMhQ!7*=5f({PpfFbooO6kQ^h&@TBu#;ko^!5LPyx>t?SmWj zuFR80M5q{v?9t`mu$|wl)i2{EHxS7$F4Hdx<=5$>E+4s;&v7}al@++W$9{%3U|lYB zL7;*=O{)kGhA7&WXS0S7Q=FBmkG5_km%;*^^~^xsJCg z$KRrS-&Xv8fp|p|+L(J+-Z#~0U4>`Qo&*!agL^lo=KD!MynDw2(ZLidGO26nAK#JR zP(N4P_5eS)LjUr%{F1Q0K@R|;VG<@{1ZbeRXiq7X<$9MEEjXoAlA`r#H8D#~$PDR= z7iYiz{K=~pZOG0f)o35wnEvv^y9aeho|)pjGyNJGvizWkK@$De6nokT8#RV^s_G$4 zliN0vWi?b*H-P(izOb6-dcKggY!R1_gTIGsx9P$STG`9%(gW38u3d?U5#ogBK+r5@ z&&jYW?ZcZph=%Y&RZ>9_I?=^K#5o*rqZr^4PzW`#CO0-w_YTg7cXswGR1zYXck=a< zQwlPJq?vsm0@GuF!#k>`U@pX03Z5V1$@7!YDw%0+KDc{xuSPhsJ7rtnZX1B_dV+-E zjF=?cKEUmR9Tm7}gX)VXuf@=LL@^Ny5$N?h;J1NJDT;JivjDU{{=YB#s!1X8piTd{nliZl4mKLP(BhNA{If2c8-&?c?3if#N53SrkLM9 zNIRI-$QoYEC3J1tJxKX1Cjezd7)b8B!hPhQ+`aL^{llbQS;}FvT>ateSD$`+a;8(J zX_3JYgpn4@zdL#Uwr(Nd%HEDKr%#lcz8bnz;as$EFAIe8UDe7{g)U#T{8uMqEuyxAjL_Z zSm>t@o0nSTF<5|yFx7B4CUe{U^xMWWRR49`WC zD-%uUK~QAaqZ%R^A=e~Bu15cjzvLVHHOl2OaI$b<9LvU$!1`B>aU(5}7tqtDJ!?DB ziWJXLnV6s5+Qngw3AmR;; z{@K&xV^57#OchkL?BjuDgKQ8=)`0*5_IBqKz~q9&|0aR^nm6#a;{OXA6dCt;vhY5{ z&dwxC9TXwKT2Tx3YTVj`Ih0UA&OL^gQo^{5!eR)pJ^p-^{o8@5--RkErRL^RNudDo zgp>-eSC;Cd3=7>eG<7;T&icDApZ)2{OH{k6l{fPp<@M+MSF7xH>P26YK03UDQL46vo5;mRsIL;f3(%R7~T%!U#v_0T+l}^ItqFF zEBH2HuvZmd%p#VeK%!*^BZPK2vWIF?7$Z!wVgBBYX&v**s8%NVCZc2YuQj{3%O#=v z9w}&+Ad(|nQiWUigF6RP^Rg1zwQ+oWBB~;Yi0>O4QY}MxBh)7L}Jk)@Vr4%StFiL7TUJJa0Qu zN{WyOB>0}5-rZ%5T&W0iWrb69S&|!PJAyfKDC)h5dy|^8pWnOn4?nqom^`tot6FsL zk6*t0&DYOfhRs5#S~iRaWAnnzHNv8#@`22u3`6lud^a`vR@>)?ST!zkeZR0-6r2E{ zZ5$L)AsG?EEFO}Bb7;T}0CQxg2kGvE&FiXQo^1Dke~8%syZaJCTr>o((#r}H21x;< zSoAl*K<^ZRrOxVav;6wYXJ5aVJErZtttD=x`03rlU);aFCqA22w}V+k3qZBG0mr?1`0TpwEl!4c&P$Fx3Ay_@cJH!oYQx(H)_cyxI0 z79sZ)!ZjWsP@b)(%qG?*8Xh0lO!_PiEsH9CMA@;iEXWz_RC1CDQ!6m9bmqYK@ z?||RCe{gg-J*fHqox}II#peiiezP&9SL@846q~dTVvJmv#9{TZ4HGNN>$25 zo5bH8E4lm|E?3b;$bI4Mcmz~MEjzpH3)KyU*PPuwNVR4~28L-!pX@XQYkJ(*FMljq z&RJmsiz2B{hj$Nd?|yji=AQBWd$(`y?@T9k+cwct58g2Vx_WwO3GM>H7mwx*t2Re6 zsnI>SZM8xI0=kDn-cki|+W>5j#o^&*CPj9OOoFN67azTs=O>ft^fc?^SIz4=iiDmH zoRjl0dab~{=q|sA(>a=6h7f=E$=(0sS0DWJ!A;B5!p{vPH7!r5AlS?E6PwNd)@th^ zC|faeff&S~{l=Dx5Y%KrCgtrtJ*YDCTvc;~XzFsz=4}un>JdeP2#~Xj! zjoAD~X(NnWdjW|}U$Tt3T#f~LqQsyOL0Kl1U~v(Y*QfJY;{j`PWN3gsdwuVqy0e2F zZNV&Ri}}ZS#pR9ge<{>52s16?UW1v}nBA(oUwwFVcULT$$X?s->(lsOpFaNVJ=^veYj({kE2v5kjcjWGVQnaF? zQ0-}_<(ewQs9I(=da0z|`THl+p`u92)bAlnfVm<*y1V;d{_g%yAMDf$gsX7yL%@}m z!*3|zy#ObfqYD95PiFY?>B%fhT~(6jKY4JpSC=h`P-qg1*rHwA2H-m%mQr~jOr;D& zcN1>!%h7&4KYK0J!S!)y^=sEqGn7Ys>q3s!68LMHMPRDt#)=-F>U2Lv#Xh5~2}wkFtf#MFFv$t;*@K6-_iA*Ns%ZW4EPnIZH=jLyeTGUVHLMCR0(Z{Hy3lm$ z2LiokCvaJ4-d6U1{WT-Ym}=|OR9pVX2YI2KmA}ic9E`vWXtIJ^pe%z?Cn% zvi!OXZIML`Tp9dSRchyriK=M8SXXUT|Mu(We|qxjxTPc2RZ48`-KhWJXAk$StI-;= z_I`pUVhU3zi1T>=Mr5S_h-#21-y)hRn#0uXB~ManViD1K=2;W-E@ENhxLEvM?r!1S zExb0cOz^?ogIYQfgw$U>J$`WtCeVcP zJ&OrQD00Q7W>YaM0~j2Q7qx4@33^!=x8>~57J>3$>%1$ z&3N``I;UDdOSueLa2VfUjqqYzgy#i(E()QSu>X6lh)t?NagKdNz#|6cs1OFx(|J3~ z;l->b0?}&B@9g0)bu~Q=%UV=}+C@FUMLBpW6NJIvQc3B${Cb+2Uww4@!=oMXGgC30 z=9qr^=+)=no_u=}jfY)O79n%q-`UAsCXJR} zxA@;*Jp26l$#Fxmi745gescH5KYsMyK^>K46m%Y;kPtvjsR^c+Vj`RN0JQkmnyN-j zB;Tz4)-;rji7RvD46Wua$7geBRkSF|ytnv*_4DwR^K+U0&sZmPA%%72Ccx#A6;}=l z&b@xTJ2zsVLfbB9r>F%1ghsQQ@}1o@l}?o*qA(K^m4)`(04EhHuU_s0u-Xj{i{C{F zwlHIRZD}`j32w|r7hX#t>{5@N%lh7hoLvs+1m7Iv(TkJQCfBK&Fh9Jte{&Zm2)c;$ zlH~eEqKw-HV0&!9+a;n9>IPfPaB)w82S@47;na`;giZhW<#DITvQ8_!vXFYS8mmX+ z+~}!yW&sABwpgZU=@o@9rTtw%Mqd)(rGNM#)L+a`#Q2VE|jGtuOFALt{`Q09W!8YLX*I)8M_Tyh&?ez#i)Mjl?lbx*gYQ}&4!`F|W zpR{4q=~TR(ivR5Wdp~{e_MV327ST~6gj7;OdI3=r>PBChOX`0L#k3EJss`>zccb<_ zYb|Et4d}2c!%pXU-sRxf#G)SvTzKOSM!^^49l5gC+UPs>*296prKY?et538N(&flG zBFF;h!_Cj`A5M1FCrS~zyIJbLR4BT9n0u?`tH#kAV@Y{MpDkIgW&snjB9F8s*2jeJ z76v5*hPmT6fBKg7R4pMlH>TGr)P~bL;+PsiuOR~clWqwZ7o?q5iQgo_9ecKWk4}l zfR#|ZW(A;wYmJdwh*dB_GMZ3S=rQ+h{)-Q9{g+>Ud|M^fwn^Ax0O~S^f`#Nhp3*DIhX23!|SSZ?2QBO|--=5|F{ppvlW*(>&7R~W~YX9LU z4?ezgP%|fx&ZJcl;c<;nSGY13DB8w7w~g5djs6JeB#q z!&ISDIwPC*{sBYYcZdVj<;3qTqIzFi2&iP4YY$-A0{NFB3HYw6|IL>Gm}h+R5+_}p zwXLw*o5&~cA64iPM-y8ta7SRv0NOSH-@OA6009=SyS`QfG~>M) zw34_v!ObZovY-e63(1^Igj#4x%PPzMin{zd>FsjA_w_is3i*$Niw`@>W!DNAG~@cq zKF~=AUAZpjS<^PzsU}Df!SG7*%{m`VyhbZX?CVN?ok{C z%Kq^3O?eUIfBxgcN1!S~s;FoMs30cN9ZmABNlwV&x!BgYSnHg(-9jN}4aHxp;APN$ zh>MY_^|BsUlY8Gt_hQp$$BqR{EW2#kb=gZwD(rTF-`dr~2`fce=&zD7yliiaND*+- zf;d6TM(}!dE^Gwmrru` zbvukVjTe_7vSpV?deVPlx$Qw9C;>$g{3?cxYp&eaUHmV5#sU)>ICzQA-q-WUNz9_+e2 zYZ|IS6Z_`MC&`bdv5T28_e2{JP!*}LFt619ANK$%k^&Z>z*SsOMh&1bOJbFnkUgnu z%qN)r$G?65!JWg}vgEw6IR->a>7T1xB^dixAv#%ld9H$nEiG zj#uEK%N)A!q5WRl((7+n_5(v5JmePca$C6nht=G@A+G#J;u8zWdj*P|w30#rERbF{ z_}!!D|N8ZlSv6@jkvc*8=;-J_|Lnt~Do@xXWD}Tb&Rz_vlxiUcfr1t^r+8U2+1RmM zumS+kRbZkFKvY!|@(xe#>|&A_;idwJhtld<)3mwsh#&(D_HQfkKYG}DqyfF*B(J3m zj@Ypmo#!__;TOMt(FQE6&Cqq(bC6|EPqVuz@7$PHXjDitsVWQ0s@HE7p}-9=^w|lQ zdd95}VbJsFd!3QuG7}t#$Ks-SIWm-s+O2nzM~WuL7hgALoxqYPClx<kx;Rkp3b`m|eqP6SJqZjjk{B#dH;~bX_Y1Cbx3I|k})fv!PRmi+vA7W1`OK$Ymoh7G!TMLv{7qRJJGUgz8E1a z+`xxX!4^mRtEv9aJ*vxRc>&Xmp8D0IZICVx}R4r^S_#eOh;_<5)>RrMlV#oNw z(caG=PIt{KcFGRX37IURED}J3(A70ekqz}eE^$d$Xc966s*(!$t!9GQQ9n$6SmoWS zLp0$XL_5bUvvGPTLVHDyQa}s1V9API_4OlnUUW z5Vf!e9Jb3A=dibtlTq$kDiJSJ;|R|jX>edI5lP`ONSQWh7V89$p1-1{u4^la`$q@6 zH8ju@p9Y+gDxK$?vu>l-wgLD~F;^IfDEw=P_ndBJcti)l1P|_{y9ayPwIn;SfBE#= zZh|HtCZf`LTP!)tcLJSo^b(We6h~QGk|12@2#PCmZ@qyg4$6x zv<#hfU5)@~49QOSLb@kM0XGJ7-OY+?vg9(9)77i-^gBtbs7#N|1mir{Gsae5JrhET zEVPqZ7@`c*NYX2$kVN(fAmEN5^mOj8PCe_i1E4TQmFG8get#G0XF%?QD;)^YP=!KG z60uzNT{zzqUAJX~R#jDqP$g7CAShg*QkF$=xyf{~vYHHqMvlZ>cgKJG!Oq|R~II>)cSj(`5L`(_RZl0elnIu{lbJ3#?}i!#ij zHQ3$>f1$o^6IJGt>a%BZqQ47t!2uJ5N*7L5F^d5x35J-oY$8PnT&zKARQ~j=`R~7e z^s1}cm`c>z`pv|D_{q`VeppY?DLOC70#a70hTp;m1)<_lu4R!-u0bIiTsoH0# zva2>g2O*ew)*U061fmQ$6i_X9NFSpHlTD(Kh|13@&1dnU3wKu&4==KW9WXQepa1&J zj3TB9dB4IZ4|b+nEKed3RA$gcNfDSO^Q{x`wgGqtT>$#^rSZHlCG{MY!HAC^9#srQ zK=jqjKYN_DM(2QPN(tn!dAUT^xQ>fJb+IA}0QF=tn`fEmD2!`@Wi%8 zbN8@LF{=z##;%K`EPdwFjY6MgCUhQX8g}mb7!3$m9NeXsUFa<#7uyxaIIK<1Uf(~Q z{=?rqynRr6cNU12;?>?CzI^uSSFdLwQcc=GfFi7tsep)H%7GzZGBsJ+MlIc=E*{8k zj~^`fzqrlIYC9zWrYebOl_~(Ln6>!y>(kGko;-fleDQSNb_mZNS*8&l;&U zO+j)#yLYhjPk;0NgPRj+Pp6gvKbJwuv;pJ!p_Yt6T*QIY(Xnt&RMo{6)yg&%C7B_& zQ)zBaIn~ypF@!)|Qf8WUoOb|?l<NHS7I-v*6s1Ms%dmP_3$tO((;l()>7 z*0^mQ#T&!Cpv^PeZdTr!_s;jDxERXW<+F>|tm=SnTa&YB!fl9|#$!Vqu{(geNI z&Qfr*AS-JU4HvR1FHU>sG{`o6$qK?ju~4Nra+Vxm!HUWv1jCh8xE;^B(=Hc9BnwI5 z;>R}+bXucMB~hh;x?}z5XCVd(zLE0UMLHfEU#YP6Bo6As39*;(cR#!J(+~D5oFwf$ zFpv7Pr?Y?l-SfxC@RVT5Ju|yPRJAN@px9TqrwF6>rqUuIDUgVCk#6+HUvk?g{Gi4G zvij6?UFSjfP&E-8AD?){ylJ05e}TU3l;U8$J%0E-z~vuVGKBv^4ZfrK7HAx+CgtmcclCI2Y3GdlcR$P5@TA|aZY0M zJc_hI+vkGMZitW+gP9sMXkHo#8?Z8}nAuL94yIb8L7pd|LL%*T+rDnwjsTSm5jcV( z;-+qeRs+OXYIB)@WXXQp)bd`v2fs!ZiiL*wCb6KktY=~pBwI=j(LmA=IGQ$hQMRaE z5ym*&srDvTsHYcMs$mno>XM{?bx^sY=ad((@Y&a|e)IXWZ%)~|RjKy2wXy;XMZ7~3`K+`BIx@Kb+lkKr> z0KQ8Pux5(b_v>LILRJECe0YCnzgF?iY%*){#W$y}@IbK!#?o(U$%!l?9YzQd@F;99h$RhnesQt*;;e}l6Sg23} z?vi6rQ7w@}#KKe4$gGQw5+V_i$dudtiQhUDLt{dr#8P+PL>5JJAxU2MxCE6s@6s~7 zqmya}9TH=zzH9vC!ND&+ytAuWBS!$$&eP-eU;p&v)5m9xSe6=E0UE)G7~&=wu7V<0 z8f4LL{1A`}jA2Y&E}j6Q1}DvuTJm73Afiw$ck)OBcVk1nJ$}&rz}f}l zpdw$csfsEja#ld1EYT(r&Douns;P_q@vGVY__wc~owNYiKqkLT(}+NBZ%*xJ4~{;* zKi#XK-XS_hL`iQ~y7^ul&CX|{)mmE=Nny00Mqv|apD}4w5}*>^+O>n)&F30fsS(-% zr=HJR&lIS#r!tB#2~&}?E4<28ilZ#k5SKa-F_MJdgeWcY(Krn)GF_jihP*Vej1~?A zy(U)=NGVl?cLZg-Xsc9_V)A`94d?MRH>0fNUB=r&TK2lVj6iT z;o;p$t*E5X&b)}#Vmz|hnwD%EfOivKhlIL#Nc-6#!SG5YH>d>%6MT68C@~8ZI{Et9 z$v3Y68=m-{@un>D`ilmDESejIf;j{O1jH3zJUxBcu+tr@_fOAS_FJQJ{o3oTE3=D0 zrnQeNIf*zh98N;xG#PSsQdFyXlRXfsp|XgN5x$sd4yN<62#sY38zY*|*ra7em~6Sq zDy!3Q8RDvp9Dk+vArLGS6_$8RK!U|att> z(_0;+>O=~TYEp&(h1IWmP0hQ@vHy!gxw{7=A|tnzxBJ46ZtVQ0pS^#3nku$k*PyD- zV84I-;@`eHId*MmPD@r#>q(^sj~pRtE?TS`6eeX|W0g;`J^q@90f=CsS(upzBS1*o zIUx`$G-wrb_f5CrNG^1H{57-$E3S^{rCmWSsuC^&)sojLn2LHJv|^<%I{fRGv)_OH z;w)RLcG=}=#J!#RA3l2TqdSwSGP;?WtHC{+rR!&+>yJH09fB$nAXJ8WFMkta3pYj3 z==+wBrr5Wvxsf0#q1F<|ZJq@~HHJ{num~dyIunGC1cLh1Fm?%8$snsQ81>p1Z4$3K z3Uj5kV83gzX2AhU|8WqKX%I*R+#!MFZu2G!R8<^Q4bAhTopd-6^SLmvNyFz#@A)^u z^*2mxHeNUavw3#gX{)_w4Zr#}Q($Tm(JwkIxo9#377VT<#k!;yHy_-~Ar+n;yWcf zK6~_f=8)8LX|+&hmfvQ$XQf;QDWWm`&`|Mujz`Z=quL9tB6#=Cog&A-c#7Ym*1vop zaFIIOCGo`sm9S>}@YYW9Q?j^}h)}wW#_6I^BSng#{F3=6tHE?NAOcH_e(BsU12tz@ zz$y~X!;mJH|Lp&OMQE1Z9DdYV6qIMNSr8}*fl#Kpmq0R?=2U29P^d~WH4m9L(}>$s{NvAVe{}C~r^;a4 zk!P-7Je&QG-+%i$w%8*LR;~7B+QmRCa z6o{6hq-@KKA4a(j<6(RJkWl|~FD-U198@5CGAdPqG&K>+0ZH0GzJ87W>))P!^Wi3{eSxDoqGqU!xOq`EfLv0TdJ_i54^tlz?I|Bf}2>W_=}iOj|=ycEK!MY z1rnlSbED?bo>golG5~?*kh3mMyJ&+Bk?4(4O1T(BLEKhK_CYLdJWR%D1C5X%izfK+ z#-7za%1Vob1%R;twiZ8P+)nhSlUxAUP63Ma2q8gjGTRIhanDdNx?Rcl4<-|I23O%l z+^TC1Uf#)_ws_gSd-p``EQjtMeE#@ZOXQHj5n?e&F%cWgOczTkSPGW`2nr+zTH<$~ zzid$j1)gv0)*rn$wLk(;^nWg_`M1ey+W>q&a9YW#suJO~2^0c)_zxgqRwfUv+| zR(X}e5`;(i&9Y$t`{e|hFcIQIB#*FJi{pm#MXwM!A@5gych4qlcNF4XrJ_rr*WTa@ zqB3j(T&Kmla^7;GPXgF^6UaFt9LXk=R9EyW`<}!vKivE2{k{FFtHkS_y;)3Nbd&$@ zZ=e3@Svw?AT#DDql z_CJ2Se`wIIOVSB*M5vjhxdEc|2#kHypp`GBL{U=8--bjASseWVz-<8u#Uaj z5sfl51fdP|IQvQGP8FIs05~|f`YfizOVDF*`zeSJN3R(RNy|l2pj@#RSn65x+?iP& zt;z_&7COSM@KuN*QPZUp{^T+kpzbd3g86{sdJ*+jei7A>J;p;L_dS z)sKdv)9&=atvz8YaL@@*)4YqOb3hP6AN2JF%aw}NMXR-dxWq-4fQpQQwZu6&q`~%O zq3W<;TaK~+6>pcKyUb98#*&;Vfoj%r=HW%FGu)!9*xZ=LRGVEB3!kPGxtr9LyD#y6 zMejklDFk26i``iLUw&9pC{jwQ=I-uZTw*4cQ?x&Qc=#WF@zHLA&(D&XLyxol;nB_4)$BM}~@s8KalblCwBGasI#oU^Kq z3YV?=|6fCT+Jp{_Lf#M>`zJaEXJ>_`NYW~v&G4Hq&VKvV^V3KHtt->$Ztd0o`1AW8 z-JfVc(W!JQF_cyPj+9IH|3f|epzn}7Tv`{hkCh^L7wX! ziZBt%usSFd1vCfe&amVWW{T)6nxh?^aBj?`U$R1W`9S7!QqbE$JOWG--n)IUlK^@a z{p#7PM&N3LDN)adSda>bQ`778E&NMJScTUOzJ7k(!qh79_Go|d;12Q}l>$LikP(B` zz#?678@#p+z`Mlz`wjDj5eB%}X(vS_nSw>b2cNt@ovIt))ED#o+b>@?L_mw?4F%Cl z;Pl%B4pMt+kh@E=NcjC{&l(re1n+L_*hlZBL@+{C$50A3Ey%tR)$;s{qhFc545A)| z?4SgromR0o;eHMAhI9%u*?Kf>&K(VAutNTea`e+rMi0+(*xALXSYI2+u8=i{#(?K+ zfJY@zQD7KZ0(smkGZ&!1O&`1 zh=^Vkh=f@xOTa4I1p4o!AMnhSDyRfpGyqRRM0B0IVy^h(xA~v{@bs&fvz+#z6KUuB zXg@mI`@4^C-rR#AGG|B;h7bYNXhs{kJzec{zX)Or8O?Kfglj?1%*;$odP#el!2}{o zQ4v!!Q&LF-m4N4|c6ScbL7fxl8rej$(?d?q^64zYs!Y0MN_fx%ghqfPpBWF57}5~Q zfww*qkcid%vH0LJtF2g1_Q3a|*$A%M+F+?ZU6Np%Tc}V|4#`BDqm2kvCxwyN9Zqm} zznW+l-lWPnOaK-+1%pq{;$$wwRmUG+nUZfZ+1%KVT|Qe-P#qsXsHf3P;M8;$`sw4Y zRkWEYrNZBmlCQK(9o>?JyNC>ONg;wRm<69cK0C>vBo$69|NJMr$e0)qg~Aqt$);=q zs}{P8+hf}RymdU4v6jMss4P{cdVMb;g<-^Ag%9rDtR)yO)#UkU^Y~dSm4rwjL_wiK zRW#@nz>){W5D}WH&hdJVm$OEyx^3nu+MhnWtqx^dDag>(hkU(DwNA0Tvqu0;BvWqiEKP6I?V&xZs;@kH4NCU^<=5 z=Z&hGY4!}2E)bezC%tU&&%b;2o6jGeg}7LDH|E=W`pXY*|MJ6~!wJ~AX;`IAbRj~h zz)*B}ma~QRG<3dL#AP~WS5=luf(jFw2na?oQt91PSY&Wan9xno+&M@SX_T#Z?Ifkh zVKzNJYfoEuQ3?kk3XOv54Qzf6Q)P^X&!Ysio?ljxe%a`APmI-pmdU7mE;b}Yh*{?W zs%X`8(c~PUhpQRAo3OoqI7w(sa#f{;3e)-QaJjI=T2^`^X~E0V0Xb1vho3#Tn{o?A z*yQnxvzIeyo!Txty@)EQLIgyps(TEHhN4iBproDj>7)7cvnGqFz`XnT{kydm<_{MR zm1_}fb&|6^whh2{uKw3w} z;`a~Ip|*&Q(%YtaM%VT3#~#J+U@+Sq0El5tYSaS^;%mb)KtF^Xh?`>oW?coSp#Cw^ z{29rDjkXRJe&(Wq-l!P?ValAmp808Z7YN+gNpuyvqbY7qSV?2z3Q;i;g$i__{Q#>J z)e(jDDq*F|)fFx*zGZM~TDYqSDH5U{>MoU4JC&x)J;6su_KWuq9^6PbrVwwl^SI&D z)A;81-GgzODZM8spW*FVAHjbL{P~c#Rs=0M?00c6Z*P| zFP?U;$Yq&ZqO6vYQ7B9i0nd z!L|YTzCpE-yTsyyiymNPm`cL2WBAzz`x9v^MNm#!eEN9S2wal~i(_mAwRF7{5eqf!V+cYWK=_VP`j%U^bpuJhp4(l_8Hepva@j%akk07FFF z(X`$~@B3DNg^vaf`09Tl)T|We0HsJwKyn@TSy+*AH z)wBjRJP@di;$6k|-l6^NPxd~(yEjF9FiED?y1ee>*MEBQ>(5S~&k;#nQdj6_gL(V5 z`v2<+R+NiMGp@#u&Cc4g+51Sg)&CFkxT3;VgD9YsDkEjZvEyI<`1*HWKR;tN?+8dO zQFH#u{e%DTi<^fN)Y57+%1%iM&>>VQQ{4UrM01JZjErT}uz*(x1+-X{ z6!-NKYE?v3nBaBHZ%(mSd%|43NkG>*tv>DiEJrTn%w7PlhyTaMv%;T zHqj+9VnbJe9)d7Lh1u=>G)1;>x{D}9=GjlXt}V_V#bFiaV-DhVvs zd^bd(z?x?B!sdN}{V!I1$)X}K=#xrnI-EA%hQKO!H-_ffuC}*#b7G&og5o#2;dsqBOW<&K-?3aj~9= zPdK~1XUWed8bTK}hc=YAKj2utsJjH<_ujPPzwE^#U2YR_ z|5knbU?ScI*ib%ya?%7KQuse5dzvK@{bnNrgsQc`qnG&V#aWBGGShDU@q=5pcAyT4 z02T|`L+Q2R0Je}{Eo%YWBTbsG7W{jlg+q z;9NgYyjsj436z-cPjNU&Rk#Gp-`O!{InO+@-9_GYdC;AvtUo z4kggZtV2~N*(CI_h5!EwMgg+9s9%l;R)CFVwZF>#-&X#A)jfcS zX$EGF=QI4~^RwT4_RZ@#LuwJN6_q!C^TFMJ`uVNH3bp%=#78sqh6jZt<{B+3&$7VC2Kthy|7^VUSF-qz=b&Kw zi7tdB=qohTWwDMiJOqG2PzIc6J>!(s-?6m+>P%q6j~IXdIJZwM6oEFK&)U`*B8pH6 zNzvWhO@~v}Xw8b8MsI(69lgHGQ~c!(+x71{0s)FjxbIZ>$%7-wGc_Qkr!P-lo+4)m zZ202^v|>Uc0uznwP#kyo^zn;UcRHFz+}KGUKdjVI$yhybxy3hN|FWcMa9j9q8-RCI z_YaA3>4hmPNmW+(L4-mWxfayGPaocyB%!6w>Sw3jm*3?19HvUR+Mp#=bO;<>#utxY zHAs{s(8Jx`dq)t!Jp^KsAXY}7YsmfE<%Wv}e;eb-g@tT+aw!%thDeC`ox|NJLd%ww z3X{$~W^Gq;$}%1<>W+c&ADadOJcqkne&D5F?{k~`mcZg-rZfDc%uh1AYaW6o+KvsPEP5UpOe*4+8le0)wy^EYD-rV0$ z|MlM=Ha7HVJX{x3!)}i=-1ame<@vR2{l?8p}I%#ENqPYZGS^=0)3{@x+ zs$A?=q3;0{B0UEL{q(ig*kya~U{WcGaCitn4cD_a&gNZNMaAG8d9DZGdJk|xqB&RP zuYGV(xV!jk$49R}P3c;ncU>330)mB!%LIP^#*Rjd=#r_3q~tP6JEIP$F>wF4cqnI=#hJiLYbH+Cc% zD6-UFJbCrH0V>NR1twa&eOvc5)o)+&`ALUVlU_;p@b*Dr|5r(aXx+=8b4ckMf6+JV z1lA+62rp0xzy^(epXqUtKIn;0<;CFM(k;T)x5hL`z#(xY|O1Id^8N zCWEf421P{cy6(C*8I-Jun^XL!pWph~d$%Sruf;*9rdjB}eev{v{qf{gi&l_TT`WXP zTeG+jloD@sJdG`SSd{Yy&x*uZGJ8=-V6#{B1@Dxq$^E(H1VpBJF_4vi{NsBP# zfTlD8P8kG|LiM=8*WbQIn$SbNd;jju+XqO1w~eW+@Tf);N?D?`+hf}Rd=H4)nhyv@ z8ug2%JAxt18d*Gvr0l!I-#nP!oP3M_CpU+P^ zr5N+^y&Ll39qb5_gH8%0nbU{Le^`MU&jCt!(cVmCGYl{mzg(DX$x0W)tBTQ;Fs;nm zMMY>Ws3)O2%vHSj*@t_QUrbXD&*37hJnZF3d(!1b+E&^u%+$`+LxW`}$O9gvlTK#3 zBLePV@ow~BP&`;voO_uC(HOls*dVvaS>Y-!>eS+_+%Y=#5}DZ}N0h|d=1jFF?8Rw! ze3rXNl$w<2X6C0y)9z@}O|`2G6cJNMRtZxRm`YOEu!$BTPVtZk5f;_zb+Xj*xMd{CmFS|5U+PL1 z)Jf55Zq*$ODno@T1R}C`6jHSfbWt#==%Rb&&C8r;b4lD&pTeOsS9$f}gK6UHiFJu# z6oo~vBDkUHx^U)S&U2xQZp!t7!4{-0WPHdc!ZSuQE z&rSkNb;$t~|ub!-N(To8t(Du|}->dMe@*YNZAkEX~~GKalxeRYU24#bzDdz&4pM09|NR8?V>NG;t|X7Asg zVE$qvgj2Q3p&j*jo}m*W1^ch^2#+8XEH&zivT_-MUewxe+#Ha>nW3ODWU&Jg6$9xW z?g0q2yilv;0zDek{YpDohmm(_S#uWebi#xyi>HB*X=&hQDq?zki z&u9PrU%q~Ff+k_+u)1nnr$;jF+Ae~14HQDEN?ENFE`iGhseI^|SGv(HoczwCQfUj9 zgOOX`;7fGp~0->TP3n7`Kg>w%d&QJzTR@*uURnELW{pnHwpa zsA|`ADpFPEIaDyIt?ja<)HI|fBHj3C;k9zgdw)eUUuWA!? zJJKEP$e!ioO=ZmPgmz)CXWdy#TD@>K0JNkD1O$>aEfPwJ^$Z|}h12t?XMt%f)#{4W z5Qbf`-nV`+vaV7c&d8yn5bd0ugK2Wo`DqTD>~xt)Yviut>HQm4V^*P)-broYFNw=e z-NvgTOGl$iF3@;_edYBUf4!-MyTS@wSqUEAzEO);Dwfh&)4e!GZ+IL`22}whR64;| z&vD#|SZ&}zjgRi_PXv;YM04ksEa2nvey#f2*8JNB;4gd#5M9)%4r8`(K#BW@_{rUy zmRq62^Nn@c?222=^BA*2`aZ$x_kU zYZR(sN(eegcCkPuKw|gsMt!StZO&>{chagh*t9%*t!#D-#^}hoT2>WzGAcyPFcv~! z2qOtiAS@GFu;>R5oS+SF5=^0$YYH`oInAl%EmJboB_S!2z)15@vOb;p>sd1oc9b9t zZa%O4^k~=iYiM*TrkX%cCS!fi@i{q<>*IT)%U>c7dihW6dnAsXET9MllOPG+yJNrl zn|mMJxlza5@@cIy&(*7L=l}l0)BpPq$H#;#qLckPp=udD^xIc$e`95a}CftexG+u4*d1f1yd__AJ_&cqh?USZ2D0GRRtE zxjo+D^`cl`lqpLlEC*}WUnfBL)k-oIZVV{cLjU@AzU0VUf6#4i$>ds86e$^dw!#xbtk zK--`hB#B~>P=qmQ+?-aoraF~Ie2!>U6tJ^~ujYByF}uMl$w_lctSVL&%^c`L9pWMj z5)XyYvLd`pH$AZ8z2Zc~Aa6hX)5W?;%2Npmc-GvB=i=F(&BD})oCSnh6X|4z`5C~f z4cCda?R=`dk(!5xX&PanM9PvvMXcY<7P(MOZtbd-R^{@ovrBe@uOl2HfJX1E=>-xB z+`U!Z-rvzYq66EU_>$*|vl_G0F6167kI0}l5l%4VwFUW%VSyt$XK zAW;ui*AQG7O)rvIev6QO3FLo1tf+{>lqv{Hgdju&QX%%xy?SH-Gf54km~hG zKAR(FVL(VN3_vFfI|LHwSuElTSH{9avHUlRa_7cDB!fzoQ1QVlaG+|2yAUQ;*zitu zNQR!w{Pk%!%Lp|G2{&|g%x@+?nqaDq=)?pf3{Q%U1fGjl*)G@qzw#PgKbu~HrCnGn z_R6(_{t|={7Pz&CUwl0In-A~aoNyvt1Vbks>gOlT$0t>6W#Z2wIG57ZPuGdRBVA(_gnX#gBBK@OO%nF5Ssc9U+oqD2R zZadM0WLa@W{PxS)KmXg4M=wrWR-u)tPQ`Cb__rTF_|;Dj_bVh~n$XO$BF^b37li3r zGQ`&pK9@@fT&n+b83=U|f71T{?EP7jBx#Z)h_Q%#s%Cz~edJY@m31FI-P7GOvqK=l z19oQ@KM#Z_5aF-vEMaMv2#W!Fwy&!0s?MxCD>E|g%MUZvPlWOCsj8`+ZV?<bboX zPC*{#cAKh*um}q?B&m5>ctWl)zkQ_F>qzaHMHW&t(W(U0T@IZb~=~mCr(Zn76OlXrOgy) zA6%PUwLD>15q*F{2f{CO`(E!ZAJ4s~d9Nf8#Gpo0vRKz}XKnc4&W$5u<@4hz)hgj+ zcxC49poU;ZeEhp74K+k0+G_{tn|CJ_k$|Kip=xp2EqWUP=<^f5{1o0Gph}8)>#Ql# z(JF;#1)sgdKmGQLQ&y^#%;w*H|JL`up{eDB9->XHUp&btr}LXvkFHNq2}}Tbx769m zyKF6dSFevz7?ROnV^xA+_$$%#$a}ftjtmfsxK@52uIVPEPB6&EBb+JNxvRLx90#90 z$M2rz^ZMplXnjzDo&pv8$^_RBs)Iy{RtU8e#o|$^0;3qEqKY8QAwGC-0nl9&ND*{f z$x*z3JLiC6{2~->a)emGGG(6e=;is`;Z&+-2J-w!+N(1A=&D??W|{=z0XOBO=rvi@ z`OYlEfE~2{-Kt8f`HvS=|0};N&E+o~1xPon% zGz7ri2O-Yk7ci8W#LShF814|)`sulR`a+)j!Az!FPX$hIE8wV3HxBE=N|Tb24N)1o z?JC8<9_^-MfPiUh*J8oNT;x-Vgo5hmIYeb|m_g*$n-GZAGq)F~adIY&E6f~`nbps( z>Fm8je*1e7BQdJ+K@4H-!s zIGf?@B&L;KJI2Jo+)S#f%?#B>@$tRpm!Cb7$+3WwnEm8C?|kDXcrTxoA2KK;0C6S`Mj;JRL@UQ zS73{&0p1yp6xo4dBp6cIy}Pz>hC+W4NoDo=rBEvaWZ(b*WH~PPGBQ}H{=4GZK?b7< zghM>QPz0DW(!ujw|N7DF-fS|n15Xu1lbN*5RQ>uiU7e&_X+kJN*ja3eB!~qPO$4qE zrSzgVGB_PA1t1wUwzU)ZM|6%WJpd3!$Rjh6xaO#*d@sEZ{>Dc>VaBpfDyzymILdX7);pMLTDykVjk z0F%&Yt{&CjdhgmhS78AJOkiE>Hxy(?r!L!4W#PNieUeR0rEM5s3;@utY;6P&E8ARK z5yEmGJ;vhE%L4o>zZ7zggy$wBO){CIfk=clJa{y#KYPiixr+L*)bvTj=!X@qA59J> zW(Z}g5;m+fs1jN*TBrO5CP2B)HSe2ry~PbgqT~KU&`BSJXb=Gg-I+t^z})3@hSPIt zaz&(Ih(>PDrgHwy!R))Y503KLp;8c{LKQJ7`wGPbZDS7FHb}v^_q1{5&kgzI!b#5P zcP2$R!3u(wo+j2th-w79MP*0^A;OzvDMUTt(?|U0-#?ns0-D%-bm!zT0gvf_5GW6U`T+aGT!bHg+x#ODFUK8tObWKG-3e& zE(g1B2mnARgBUi;?63d;g~Dm1nFcVAK&4U69ESxf6~djA%CfsbmC^$Mguu0|G|FWH zeuDs@<7sIJ0Lr+mW6DvA5K|$`l^=JCi3dV4%lQ0ZeD?79%(bP~lW99YOUTDH-@kq3 z+aF9Ukch}xpvjaDhIcxU(knlalma%iclRas7y|US7&xxC35O@ zMn$aKmFN^RsOZ%9mSNJ!e73in>;|n-ya1F0MVxS5MIOev#LF%i`gi?q{+gD4Pf;{4hqe|WpD^YbIkHH)Z` zgr)}eGRKoy^RkUrQic%*niRBya0!2+OA>I#t+v{dET8u)e)F7f5I zf#2*V@mM~4*cydM&e_ZWGQ6&|GLdKx5{?v=+52PEpOFR#|iY zVyf*&?_T?>AKw1n`;#ievLm;(X;Oq|L8N5b>yr)CyxvQd@|JZ}_OzwH@V;A}D@6gT zs!5IvxT-MROyO;f*_|V~HHibBS7;I0b4yWLeKOBaUY@;}wX;AAb4ej73M-3!f(c4z z>xEwBbDW0Qr=B56Ni0%QpUx02k%1P*ba39ti}N^dBs1keW`??=sWG4Wiw|$q*DO!i zn357hL#(qt>)yS_rqsV}>#X1}-Rb1D>%V(kJPNOkK%`2t&lx!>OrQZc5s`BXRaa|( z-#<7zcWpB3lrTU2@ptYV8Z2^^ENAb;WtC>qCH~9H_i>J8){w6 zfq(gQ{=3H~=K)DpVg7GEy7S>pO$`z$(W+rihgk#!X1xMjC+?0Vb>7X3u%vMDr@)ro zfaQT~nE(KYVw#NgAB~fdUoX$juLA9uf4Vq`vK6eFOq_CTNNt@m=xV)VxdNZXF3iSX`&6{|3 za(*`V5T#mB(b5zlvx+CTr_Bdfs_Pn>XUPNzRaJqCLxfgC%(S=vS{?#yjJ)NqyBq)8 zCICBbwlV?OjWkyNI_PbW<^f|mFM1a8;2fnCb(K(L;Q1;3@ZjX$&YhpbC+)RT?UC+0l8^@n_KbTPO$@C$_SIUFI^e zM8WE!!B7JVq?-V+TP?Vp0KAQh=Ui1a1ex7DfF35|ZCh6rJv14C#zT_2pas5og5TbI z@^T(oCz)cVh{oEp_itVK=Itvtk5HFltCXS(2k3=|4VOfC2MzUDF}vSE{U4Qx>{S_- z7LvAg_hUt~cnc9|_}$&HeG_1nk{TjmWH`3CXC43$8`4NRiiH}^{(axaE!?9sLigOIXE(fy%wPzVG?5C{h|k)eT# zQayP-YaH`7f&x<~gPbVi^x3<|_Kjz%%BH(R{lRQ4kW8Tr7G)jh2U#I@&BIEhADj2R?n&{_MB+tHT?! zv*s}2qq|2x{AQgpg)JnN16L6W7YbOFX&5eXz$F0qB!gLNJn|F@o-+b%lO@qwiaH6yJT&8L6F0`VXBfW4t-zET9UjoM3CcqGI(eDHF z*Myf_6_o_fAcM%^A_&1OcvcdzQ5 z<2aIL;<+9qSJgyBG$0~cLA29e7|mP9w4e2+)EFCJk8P^4&(}TxU}YzIFR`{|I?xCJ ztbhRmFiWL_MG>euaWcn?8GiZM)0Yj-18S8Z)Z0UoE4BQ^4{xaBphnpPNmdRih<5B* z5{lx_05inpNcWA#^R=&%F|c`iUodp(*#TB$fX!P0Arcy-OY3aGt_Uyl zM-OdLoyp|hIqsk2S+1l$cAW%7=qi!mNqBrvT|b&uK$0*(83HGRZ~~Sl3nyP$yk>!x zErhXWMtL_mJV(F`R<(}P^L#do9I45K8X5CuZV9H~{N+1Gc6*xd9yC`hC#Qf&2!Tpc zRk5zWsL&)y#R?&5n*_zS&eXO6%Y^^{BRjBl08sox1W<8-Q_34KPz?%?NuB0RBa?bY zJe%X^zkT|w(WZ@w$+6{s|Kr<-iW+W$+)X_wpwa~bR)es_W!$&~0Dr6)pzw6q+lF^; zjzS7us!E!`y%+e|Z|=X42`a1d%MWi}`N?}%4gnK5n<&Pd4($Md41$TW3;@={aTkpC z003BKWU`OtL)==?XINYRU2Fjd*$RTi36~l{dX|{Cji`uf(>mm!<-yZ)`_<#~CLPaO zv`8|UI>aMS>dM<$tvITxqdFZ_S}7H|j6Hgz$D!rD=wuHseYki;@mmQ>hB!4-o$}tmOMnyOhxom}Pm?;u~6z6*Ae{?~OF z_6)>bM_=|W_s4jFgNOQ>vDgU9av?y)5D=nbm5Vfl1HZr5{{G>!GY3-b0d>?#m`~ol zeeJ{dj*cg&i3(s2Qv`#gh^eS4yamI}EH1?*-xdJqI{5@Sl=o#Q27v@57ttV4Lv*bE zGBh$Z24h-gjY|OV#s@a;jhTt)$UP6Ln!^J{(-@(6@)E!M?8Wo5=G@_hS?aAYt{hgk zuN{8)?sN)F6hfVscv5}cwk4cSOx&wQp|+_09*D4eECM}6+bt%pH})% zJ9g6OjBcMg7!BwJ0|~0O^&mXeFT)<4@xh6nBJK(}a5UkCtpH=8b;GYQ;EUIl3nZy7?-Ks#cq2j;p5K3d zGIycW1SnaUYx?`QuYY*=;3%OMm>@ig$5a8a^iYwis@jOlaQ;Rc5M8Aw7$jNCx9xp- zS9U!K6r=HhdanHdE&92E2)mPyfNM~A2>{+K0DuI9EI^CB+leX^4z$2YgWumf|Kjn> zkh-P71QOy+O@Ht9wU6FEJgy-QahilKLSzx1?=%94(Qs#pKiJVGWwFePWQmV6ZH* zQQ;Epq>{thrnS~Zc@qF5sSWXRSV-+(d6ygp`P=vaW3gO(IaCSXl&Ch^e3!D8Mc4r!f%-Yd)UqmmyDKKhe*P ze=Y8HBG3W=5M|Iys6s`=BfA)D`R)Du%RfAASgY3X+4n!Z_R+hVTGT@3P*pCLQu?}T zgSX-3yx>2Xn1k=?Fart&pT}28&6i`0=zh&{9XVSCyj6?Cv5W(4qS> z_^`Fvo2G4BZ!>d9Ai1cEI>UrXD0z-{RtvAz@&5Jd$mU0yYc$3zq-qk=GJzCLBBp9Z zIzx)>{Bp&=g95aFU|ldZA-gO7mHWP$4*+_V>!p^UEXsoM{9#yi>$WTu(Ql#)au6cP zkbx&J@P|j|U%WUy$-y)MB+<<~81T`1(@HR{pa7jeit7i}hj(wha}6~h4tQA7Lgk4?OD9HUy)NnR2nJ$l z!Hg(6i`?2?-6jdT0swaO@-GAc*rWxtPb&%|iE58RWD<{vQii*Ok?Gwi(}74WsuQF$ zOh0{iewI^ttH13VWSUS<9!zvD~fAV6)8K-5EDxf?JON+D*)K0 zJw1f^-S#p&vdJ#J=`u)^vI9AA53S_vOs1*6oZ-~*(~lpVwl$?{=d*Wi9Q^tB4=2D> zX$%u{FH#1>c8Yd=&7NR!dAS4tf1DpDhyVmI5Z&S~we<1ZoF|jI%?LvaoV57cpFerg zidw4j?9P?RpMCf0VS)w_t#(X10abea^e*b`lxJS)^p63+(qv!@09cU$-O+{_jKD}Z zopi!UB7{VQ69_~E5ej#WHpnKdL7mgl)46>1Z2r7Kw&^U3rD^H6m)%e?5o0pZ+$M#p zBr79OF&*2R1Ca12#)_Gog?E;k=(Oz|8X}EnRVK;F=gH3x#BUu+*LLjK^LTOZu54{B7!+MUt4!Hf9i zLf2aW0Dbd*3;z<8nlJFPm&4HdfC#E=(hZO{6~Vq z-bsBl;OkX-yS&xw0_|foxn=~`#gN$C`(F{BUcYi9B-l~?Zvz9t2zocf3dt-y&xpuT z1ZYTvNSISv)kf=6pFDigK6%-kdDZG6tAnsQL`WA2fh(f;Cy1z!)1=nzfI*is5lQAQ z6Vc1Rlv#jcss^$smx{nZZ)X!k9nJMA-oG)qI?0JAQ_WR`P-uw?NiHIWTBt=hLDZz! zPU|CP1zp~Tnk4~S0qUa{SE|Lv%;O~C^@aZXCkLWm_ z;;(*kbR;-XR3L(+hSOAfIi-$PR3PkS^WhQz{80z=zK|+h+d<^5ux^2bd0^%MV2a=0 z$Hxy&XQ8z@W-q?~!L9GSJDCy&r$o=FQ>6$gaHx!Sbhl!FjXMA<%fSOY7y|$iS#bj1 z9ejflVPHti!5j!Dyiyu%%5@T|h4M(D z6k=?+a@-H-#)4+n*RNgPDz$1B1`AQ4*jbeYM~Xo>mwY`jP-(yauvd{TG_h zcv*0uxEAYx6atSsLEy_?n5DJp<1Bs|$= zG5N*|%IGqD0RSD9%cB4QROA2vP?qE=8ZZzkpn%dTk&OWX(y$*8(oFy^FK@DA&{k_zaJMtlAE@Y^3;yL*I*cEjM(8A51ME`!GbV zx7ZguK6a!oLZOcJ5RH^CWRO8>|x0(E6ULO{=r zM`H))QJ?1O$r+xU>#0|*ONSv7>04?|yjn-VNNSVHvfC zf^EyHN{o(n7}5YAd-gXUV2=@4kB-(RPH3E}0iry8y)&8%r2qrT&-YD+ksOJ8Zsm zM~<(NE+G`X&S;AqaxOky6c%kWdLVa#Xk|7U9!eK$B4s@~Z4aKF z$^ECZ2B{TsE%JoQ07xr|t%NPVXi5A&BB83d3B`0-ry?y+_T<^ClCPk|30=`20Zq zotsxbxO;T<7;rH9qpqhd5B%lTH@FN_wyiF2n#gT9JHE#8rxaL))W1a3(8WMqmfYaL z4kbgQ(}btMb#Y@W{6YCTe1dQ0c;NO1(1@u##@e#w*;U{UDrOn?9{J}FpPjUfG-;_; z*Ewe}Up=VbyEA$BHfmt%(10k4l7T8b!wl#JXu4CiSD{&nC*+2AAfwY{n<&@%V1V54 z0d7o8T-Bifx$7~~O7>*0A! z9=-HOC;7}ZOBJ2uemC?56)=E`48+hSSpXrCvimy{Q7zwy5Daw+gCRsBt7zZ5et7Fp zuhmV>vl{b+Y^1^z6;&c3lqj8kDH4JM00`A64h9&>fbKH^khM6^Es19F-|;nEK4!XJ z)$;G?gwF>gl**@}$iR~`{Op&X&!VOtx~D3M@_CBW@4x@fH}6h`pu=iJ6f_F&{OTb@ z>aV|H!M%t>XI2WbZaAp7`RfXd2p*DqB(>Aa)3)IuY=6p7- zO!BNYMmtYtts_;FHlhgNcy6_q>f_S#CP(LSmjR^jO!SSNz8kdMP1!Pj4K73cm1fM+ z25_h6NYDi=`vD|Di3oJf;u!8EpnL8Zn*gCs3sjj^Mxxe|CP+1f|8#!ny71Vz9|yLoHpHc-Bd zEz%0xI6u6gCBJ;H?)SZxuDX}Bvy54O-c7DCJLd*V;xOy^zCXaaLrx@1X&Eb_{mLl> z49})WLMgU6jDqMIpVxI&;G&l0N3tv&r({HwqShlwrn-vo#mnA6M_DTQDGZH)-6X># z3G%f>1QcdsJfl3>mSEf~~iRM%>X=0Wti!#|^Qi?JF zL?<~FU)?!;(KDrR=+Lem4;v2u{`iq$ael+`CIBRa^O#$e${uNwB!OaO>YhsjVIjOm zL`3ww4iXRzA|MnC5iT^HSi0-VNOZ;e-aOY|4L6i|D}P;y{~NmUW8L@VQ>O4s^JCfB?ia`=35s9KGy==DQNlS;3IbK&?DAf>*lEF13GNOgI$c;r#Y%932O>jkM zp%{sjY$w*q`}8PLRnw50$`~4|-PC@q`Y!-r3{Dnbtn7)ZOVcwImifgqYNIgh#k^TxN;RgVRN23`JzM zdXts&vG_!HNs)^jCd;=US)hG|vr7oGg%Z=&G?I&jGPI#Y0k7VAQWa*XW}UXeu$;i^ zJXh4vl!deihC-AIrj8T@xF|&g{#6D53nJ4P00?9YJ|2{C`(uW(cC-zHRS6-IK%}fh zSy>wj0!T?cyx`ulv1)2+mKkcsi14gEqnlm(7~MHU8D4Xnuj!-v9uWn>^&b;2QoY=&&#F3~H;0FZ zxy=lSNmK-ova$_TgBC|*Q4S(zOJYFEO91dj0l@$L|8sizDZD`d;K;#9s7wr{TPbkS zjAW^fK;&#v*6*?k5E6uF@*p~COA45h&1dJ6lmze`$*fc0kq!wDB90t{)JGQbgEn*b zhwXdA%ORNK$>z8y+&Ban2i#GK&(iu>pVE%PZWP4?6bMi%Jkeq#Kv7CkO`*xP3Uo38 zrSvaKJS#(WG9XljFiSWXpu@Y!3ue=TzStQAch!HH^L7s|vLc&((ELmqGpMt zyMr(@qAUF)ymUqxQdbkx)HHL^z-aulOamL<05^^>MmbPcZzX*K!&lg7CIqgblsc=@ zTr`B#ObGm@GlouG4?N$Jyta(Us#Yv%zV2OIF&DkH!tD^%S#EB`RjT z%OarzPrr8!pduL2Z;^F~i_q2yO%#I|)gUTn0i#U^2;ufiP1`!b||MLUv5llW3LIi zXH8-$WhYcxZ>H0OrXeZGgkvqdOazPw2Ur|lJCV-CYZQ6i2I!thK=!`lHZh-z0Kf&k|FxvE*(!L;CO~MvGeDBQ zG$p`-wz^QU(2?2`KuieJGLp2|0(P*1PAmO4qJ&6e=tU z2=tP0deMh&5N)d9(iWKvs8zJI_7_QKQ#WddWG4Yp<*xSA>T#|ZQmv@SsOPc_0A%pz zQTI$_OQ~tYMYW!sw*j4`ROMzqNs6{TsEv6}L1=2j98-#lc#=kWjyz9VNvD4ZAVLu3 z?Q_xpxxBoAfcVMT<)`^3FQrEvKm<$>Knp~Ld(ZGMzk10qi)Mn8?|$^o-P=_XM1z#j z1m=Oi|M{b5r`aY~09?OPfB#$6wHoG_7;;7%5Zko1IbN%hr69W;SeJhp)j5lmEa_&X zW9sJ)QMP^pfF(cn5=OIc>d?S*N7-Ls02KAF7?<DC z@A@xx?g8)ffUh4=IQn9ieWqLQet-H5pWl0OdOC}AaAwmMCLri0aWt*oxjngk6B9+H zP(mD)B59G*=vwp}$xpaYRg3AQ2KM4lXSr6=;|T z&RTr&2=^b)XN^Z^79$h&IzLyRT{${@|J`?Q-ROq6b%n@FldDc%v1?274fuW~c#)6XeXL;Cr>jEHlAbaY!&5|WZV+8(s#}#0VnNXy zE!viyH|bL6aB$JeGFitM8aPDk@`!X;qL&nfsYn?Ltt|Cg+UW92!8Sd*^?|@pEv#!x zY+L_b7mOPbTh|UnMF{Aq1PXXSCU`c-Pd}bLpU*6r&(3ciO@8v-Yg570K*7V8_{DD@ zo^jd|lUly>;qiOdaAaW9X?P6rCpuO2OBv!f8V~<)dI&?fxd z$LEip%_KC%?EM@1gOBb^Do7v++yO-keEOjI_20I0(h8Ih`P zg1m&n(UbROxq(0Iu5A%zTk;EA$c8S!{YC=Ma#eZt9--sFdTSx>rEC+Wh!WjPT zujC?LmarNF2Ecj`Ze&tCG7T64%*#97G%nTCPWwBUdtC9j|HS3AFNw|Z5stF zhfCSrcPUG;T$1D5JWp7@Be91E@TO(H&9@iXEAekHutr;s3jiQNSS9Yr-f5UTe8k`1 zfATm>BS}S7gb{+M6t%_e>qqb2uCE_K0~Sz(F%%NC@nPR*snH%t{~31KiZ&|m#{Jq& zJzokI9j~uq_~{fgj1IuX2E3#I11ik!R;L;9KmYBgtxN%#u=#KQ`u!?EQWPFEQ^fEo zgd$VuNI>t({nc&IjQh*oj_*pX!*TG`kR{rWtWN=!EVc{;;1H)Gp#`3u;?cAC?9tQ4 zB~z&e>12y5&fdGKAKbZi<7!oqMr{zlGfYMRjNL0AbWvUd*zb8F@8<&`J3II5*N;*R zvEmoDZ6L5sHgtzI7|PNuw)=&xE(RbtIl#Bv|1lZRMJRTOMf|J>&LtNjr%x&hbH6NesIkmKF)Zs@Vt za9c#w@+f8DH<9f;r|D!4d~y%JzW2mU0I#F@&il8%|G^|VjBuh+{Os4yKYi9jHO=kW z2Y0Ug#di)Tfs~PY08m1h?_Srxyu2O&`0V5o0KB(XGK{up1y~3QTmF8OEvW|s2 z3G|xNtLaHqx2y+jmH*!u-gRx+cd6oE!v%TmCGE-<}?fG}!x%R^ks%dG+X{Wod;S@At zCxO5Dm!~hYQZ<_M+XwRF5AVEt6>Lh*iuG1*sc~aGC&hvSA)7+yjv+voYDIu@ZRT%96Ts#3N1?NsTfEDF6F5b&F44|t$-%VIz^&|RM1OTfQ zgK&6X1cD4g&B~yx6*zEmj?W*=?>|0m!eEoeLrsCK(Ijb)5A?k|*YDh{Ccpt;vE|h3 z5XayaM|7L4GE4~;qAs!u9Qp(3D-HlkTT)sI14>w7kZX6a1XQxfDe%Ak?CD9aYOAVz z^56ZtTN6T&=}}d6+aUED+=A$rfGa}2(i9-U5`ma9NQ(4CpXGHCS?%>M;Ln;S4gqA9 zCgG&PgGaOb4_=-(>X^6`5y=p?isrDwook18ZysDdKqU~OQV0>gFnd{OImps!CCgps zw+#Tc-Iz;y@Ul$n=cWn3I9tg^QHrgLXCmCP2C!)Yu+6V#iT6K(3+|;5?EyePf0lt7 zw0NREl8ubdu)GdSS?Z>9k(hdgi9x@vSC~*00lUd`Y5qfX-Dq;fTV_l>-7x{!GUJuy z0rKVyvbx~A`9YR?jv}g1_Yjpx^5_&l`_02Ab0AHWaeX3x`Q2+bYIL52rWAS-fBC)ZheYDYxnzuhFQ))+FeKhPiOWyn4PM-HT~~QNx0Es^SNh3) z|J4`IU$k;Couv8G|I?p;aI=E;6wd&XK*J$yD7mr+GyKCRk6+G_s#^2;%>(^~n6@tw~Oy_3WPq!2mE1}p$ob*!n z6I#q0dkio#E9j*{V_D8<@irq?bfY_`Fbxg`Iagwtj8xaOo4l5(=n@84?*I4l3uBjn zZS9s#7=Rli@>c2P<@mN5$0^GqZoB%id#(k)+48fP!1ro#GA83MnuNV3osf!55l3o) z(-se($@oc05G8rTciS~Ad8(F{`22Hp6ii?OnLI({q?OWL(vKW zR%Jh?CkI5KGzJnz*5+SPbhKe}L`ppIloFx~T*KMkFrdX|mvmQwLI5}+YB`{(b8K|t z$tiw$|HVmz`7D#BS_4JQD$~zzUYWde^Z3rS>dFKaVG#mEXOyw%cMmo83e`teCY^5? z04$GKw`u;Y(lW?ck4TFL>8-eS4 z!4Y}!Vg!X^)c3q~<;8C_Z3KwE(?4q8bc~;}9-xDerYKB-<#JzT$7Y&t%R4*!O<3FC z-Rk(eFn-){U=;YlUI^S&n6sqR3eJeX{l%jv=N{E0JWt^N@z3A6nP5#jP39g*$q7ba zqQ85JfBx;G^QZwi*7hggy7}%grV2rLgr?NAc~V(qAL)NwUf#?Ez%M_AH~E2@FrqeV zT5n;w#{c`LPo6c3R7Z3Ay?2j(@WJ7<#nj*d08URP;vtaC$X1;Se*egS{rQWANdy(z zZ{C`I=iS4r323vZc9n6&q60mepAPFvqQ!%m*Q%28c3a;?nB^eRhHdlK z4buQDX)j+?{ez(B%HGxb0>Jo5xqVAm{&iaZEB$}MduK3}-Uw{eB*L=yG!Ag(j5F{0L5gBb^!k^WQz0%aIY&IQj4X z-K~SjiYx?JcMI}efCxcRM6u-}^{YAq*oX;KB_ilFGgahF1SP2(96(UWBw{Kg-9yaU zKnRfVa)$el^OI*UpPjdj9%h&TNgxr3W|DY(kUn_#>S2YWDW--Luy740>6$q3C&2M* z8tB!sWw!QzEC5(wfDN_)o7V%%xLYm)02qz3SNc&aZKg2*Sfm~<34LxOsg|4=V?!~H zA8Pz`fsw=>WE8PunftUhzwHUBDxzGnno$5MP=SuK#xR{8Z~Csf%a6&ako^Gwx9a_O z543U@%BYrDZE|suv$U@>Zu+Zxv)?{=+0g2`W_$jF5AS?<8wY`kQK{tYu;dY< z(Mmd3{Pb5Z?>~1Bt0nFn*0m+at33i`>wJg^BbX15h>Ub3_cBd#(zJ<-DKemXhZBzYU4Z&QEo7lGg zJKRBK3#WhMPViWC;*S6TU{Gn6&IXt(HeH3x*-Pd@L{r%dM=y!tfCFvd#W}useDdJw z$vj(@I#7iO)Kj8(o1(pS<=`84uHL$W3b5!@gWA?fs-@y7Txeg*>WM70uEjFqxQLOj zu+yzjgbIm%0$_k@=t${46B1ILDgMX5dotG}V@%`ZKmEHq(`bw?5V8ifV_z>A8yemI zUwox`xLlD>7$PKLn))!*)0ig<{o>`L=VxIPTGcv&TA4~T zlFzS9aOe8b-J3^O50RwX>lK1U?N~$Gei^-%6)lR5D1w_R{*3^jWG{rEXp432rp`BTu~poeF|eO_$U3GR zW1a;zka2`pE|Ev2?5B z;ybtAyIC|JNNQz3c2zjut_s$lAON_W0KC!s0zfn}P{|4L|NYthlQ?vm`uT@~2{4L7! zWvRR?AA83-Ld&jKY0axpx|zLD5)zug$sA8#;@-m-FHRfQ(;1`{|%hiuNnY=K!KoS3JDdeQc*-ai~k@c zP@OCO^WQz4>7il(0Ie~ELROuWanF+~66k?pjPTX{h`1tkB_^?6W9X)5wT4Z<{epS9{{kFp17?W zwKUmS6#C2O%W%CjQmP`Nyf`n6T(wXYx4U{PV{%%2dLyO`3oEqnn2b;*ms!DE2oK1hy#xt=9Kr2bOIZV2wbDo%>ar z<27Z|3fIQDr)+Qr+PMC;{?WZ10D#d1V3lhj5`#VPFb)XHSh5L2)`vKx2&@F*#LE+W z{^Z4{PtUT}QHEt^W{DB>wh~@DtnXewx_wozOppXtSnLW9FhT(QSh$>`YM%Tl!Lo3B_lv3MI2O#6&g*HqWW6M*Fx&ke47D;+3Y z2M(Gz!i1Fx>EE)S#-!$JlKE2C!V_>GG{^-7`}lgoOFS)kdqG_>BL>cB}kwwH5R z*B7u95vmrTyPKsDoV57IPaZ#QBCO8YkE;0V@87;PMU~r12(u=WnoNLj1ys%HM9w;S z-r#S4e*Y{ECn=2-7SKC&S6$mlxV(I+lyUD;1?Y`NQw1Gppl5>r`pMH5 zjkTdwwBNlw{n0lMCV;Tb!Bo}JW)-D}rsM(-2*T(Zt(dk_pHcttl%M?J(JU*VwaI(e zrhope=>#aOiJ}?6LTrwarR4}4bmum=cJ%iw{kPs;)}n1}H4Yn=w7Sh-S;XV=I@!zo z40nv3?f(_68zHQS=`V`^rOm7n^JfUG)>HergZzUv3eaBd{Jqw&u*17?)AcJ9pq)#@ z|JoCPLB@1-21SX!I0cs4ogHlDpw`)`FiF#9sHDm|xo#i>FESoI$Ac#?o}bRzFk0=! ziPODORA?*Ys|V@sjpIAlCx=sb+X*ZR057V$y%{G)3g3%afXG)|1&BrU6OdlDlY*?E zgfajT+D!1D|L)054+Wg?^gsRe?PIf4$0f2#gtvIYkcG_6;)MWAV05mB|S!A(HX z5>HR?;Mwf)$@y8!78Wp%5TS{vgwRcKl<>XxZX6~#m`arjiv?DaU`g=3g-ySE{#FXC zf8F{Yo$P;KxC=pHYot&aLIKlU@IU?CqnRE+Fp0DO^w)RB0DuBYhB%?1Dn&RrfPK{) zMr%qy>_A_RZbVY1AZIf?J!w9Dbb6kpY26{BRpCXEkt8aKiShQ;>3g@1t{$M$Lg(?M z%3$QMLhCG{%(`AlbTw;PMOpxWk z2w?>SjD@OK)Pc77S1x5JppyVv7}X4@CI;DzVW!?Y0WWW9tbK0%2)B0@>8AukvKb|j z-ao$61nSk(L^m4xZgctF-BcRgMb|UP$`;vjlG627PPu^xRV^ZPyf^ zRD(nbe`sdu~O8(HsR+tB7c{wVxd%IX*hLdF|?rYt_*NR@g;7nRl$cxHh}khj7YQ`kUa( z>1Zx7EY@EdqZAwz>6}R+h!DD>0|1aup;Iwq%agwDQ_Gfym#mL#xV{Ii4c!U5uTwUQuMDxhzJBJCLWGH8ue}z zV@~x*F-(>|i~e(GndH260I&sOl!?V!juTd%8 zqZ==@O~*!=(yRRpxoLF*uo8#+;WGu%i4gP(bXXSc;X-Y?p9sVl0ANHw#fCPPta$ZY z1OP;PAUCu*M7QUKj)x{a`i}yZ%Pxc}A^<^4!0_x8|M1DD&*X?!S9x}GYJdI18%K&N zKo2NIEti&0K!Hl(4k+AFrJQpk)xlik7k|jVd+mz?-Q4J5lq(i;V>pmewC+C3gs*gtXpP(|Xph3HhgA{PujFj}E8b z|L!-g)UbXDvJWB!fM)=NvZ(BJMnrUPt4V!252^L(48QpG((sFa;0feQXH}o7I%_i>uF=dzWid#ffZX8uy%LxF>+Yk5stZ5g|9!_wt&8pUstq%E<_HCCA_!0 zK_%NN)+K`4q7oiu)s?pFY6O?=1QahF)pE*&NKs)wo8$ROJbe1%*~$4lgH{phke1Y> zrXt=N{mMao_r|r`*Yv1DB}nC@jdE^w&Y+Zjw*tbR6x6HkmeylW|5XWZ`?d1nJ$8wi zn?I1)@^NjeytwowFSQ;;mLW<=WXk|7Dh=s){2Bp7z)YDNO=%w5B>P|g_Tf3ISm ztrCvhPLo_csBd4ta`(pMAeCt?6i`xn5?hQuhfUaH`oE_xC z$A68rv>k)!^$7sB_y&x6Qe(9`S;RVXZYt&K07%(yLJ8AM|W@CxnfBWkzI>; z7C{lF!f)(qip5|?OQ|M{TI-T8bO`|75CEV=uY#vN0DvGME(DMWks2O|Kr+ndoum77 zT8th$QEhZ`-3pXW8`%*@2T4K-+#{^6XBk=X@HzhFS6`gLn&~vp&p*00{flp3Jph=q zB@1_>Dmxl|2-#Da$<3-ATdVvaUS(S#uz?7K?OdR3AYmB*V12=BTRzyQ4Zi~bY^3#X zWdHAIu?*@87XSdkjD2bC2&I60RB0q=ah_P$U(Bw^W%+nhE}w zzxmwjBi2mq+5hFo?_U8bKnydum?Bd^K~Uf#La{I*C;?fd)s^y|Ywb5K*Rbn5#A_eV z($_uA^9DR6Wm~fUz{Rxp9hcFWSZ1OO!c5DeRGBi;;DEFH_hg0#Pv?)GzMSW9TBZg} zL<_%}(M(cKk`L;1=f<^nZdL~giKw-#sF(HNiy+)r>lE9MVsR0}M|MBV>(%%B-)0*h z^}-g_qEQvG4^Y_=V8RO&{iDSlx#@ZtpRr4(@|*L5fJzV+*82!_ zV<=P0InRJvi{K`lwg>nk}5kg zL=JG)pv_2v14HTpYepiAz8h7Tg#Ak@0H6dVCh1)rF9Eq4B^u(h2mZ+) z9?a~Jg33?6{m!)?d~jSl(9WlIog<>0V;m2PW!nT`Yfy__6rlC+7vOF!Hf(nh-ai~) zn1L=N*lbhm*%oj&xO(mG@e{id0yfF}>;nK+3O!jy5i1jeuQvcF#sbTQbEnP$5eHf# z1TZ*oa*k&&^A`_ZG+CSM4xuKBLbdDt<;IW?YrS#h;O4cXD~EDeVM0_RuTquXkD}L# zLGcoc?mJi0=>3CKMu%1@AY||#>oZ>O-n?l}y`aAh{}eI_7xi-)baV8}xK)7=XqdTL zC&wBt#qr35P&fgvY$o_${(qn6bTISt1M&axiFuGfitdD>ct`|7yG^ZX^x^{x0AM`;U@x$% zmu2iF0QfSW&;kII@pnhYEC4|ILk$oby9@wRnax-waYt3by_`%eJ7(xseH83Yt$=pt zMu)MZD4Dk&T2JN<8nWQ$zdrf=Ns}e1wH404_wLPa-99{4MB9cHXre+Xv$8&s@|8L< z!oB2~#{gjC4Ymc-t$qSqCIEcJrJr3*k)6u@`~173WMAU*ZsYLi#f!q*ui;AX|02L3 zyD`#N6aXwJ+zXG&vdgOhP&GQMsd8SJWF-?gim<6tp1wN_l zu+r<3bo0v5wQH%aN_Pn=NZmBeOZM!&9Z+h~73IURh0$WAbEu0KwgY|TJlZc>yVu}r z!IiP(U~Moepoi|OB`=%r0{{?&nm~h@!O9xJfBA>|&(524P+hI{KYsu2#Gyc%paDE! z(UZP~UIY!hOpLXr{|X>k?0yJE`jou<`o|xbjGdteChzE$!&=Mg)T9h;!aFXqB|zYT z93iSO1kieS<%`qy*^Al3$MeQh;~rwPimELc;VKk(B~crXr*`A&;mvE4YX?Xf*1b6- z?V0*zMkL*qQj7qAeN6$tMj6lx>v4znDegC$+;5oxY0XR2{*@fY8D_jZkm($P{7qC!;{Ww(ArUSOQJx&yF9 zc5|gbUk3oYN3pob#%2BS*SVWtzWT>b>#l8tpMAD?cT<420f60oqvdKo#sFX+qu;M1 z0FV{qu6~7=BH}>`M3o}_YD#~8DKc={;PDGQdUo>cbUte%#6%6MB#MzuG6h{2mC0dk zH?JJsysB3ZFfq`}p1@+bTsU@gc63_p5^5O;h;FC@OAlT!UJvNs#FkMt3)f_!@h=-o z=b>UQY0uoWjPoQyNr*%;1+$AHi?pi0yMO-p#o5{X{N0<^e)R5@DgZD?%gP{XURoIp zDN5kd*@5+_W0~hpS-hnol67SLcX^Uqk@%Y8*i?dlvidGtxw*kP+L=oYjo3PTjdN{d#?daxJy>fsgg;fR( z^iMiylTlTAXQ;({92Qx=#sC1@<(PK>fPL=xO0E4p+y57~`F8+--H)^Xh;iBmhrUBuzVGU^ zSoDl8FP8w|O9FsC+>f#5FiW000qT zma+#`#k6t4 z8o5?XlhlU0x2_!>*p)-n6%?J*u$3OwXtjMfg2r-z;|{m>#yU$V2Acr@I{*+vvtR^) zHY|uz5b6SGm=F-BL`d0LN)nLps>(-fWiY_*#?z}7`Ws=xt5pu0YW;l@%8uo<-8pFdyJFLg zw*Y|cPLW#Nze78nz9LfMBCW}@2LJf0`!8n#*5ZDBDnI$o%{#|Hi#j1P8Euu!-8GrB z(^xL^vjqU4MbNCLD+t@%TC$;kpb%39B)l*^hwL~yU_HD zXZY!F9<@4|=cp|19LbNqb^U6EifuVh+3|)sc9!3B4!auwU=Iq=g~;Jm{f5{7w5QDF zo}|egn^o%rfvvJ@yx@rz#p^{y{Mri`aL(&g_VP6{PN%R}p(&qvpAjggmUCtxrBoJn zi&dV(#|+rA>suBELeKZ0 zrdN(}RKqeV8NMT+sCWtbwE>LHkJ)L3>2jsCD0CNbWVHi6Rvee%VPzDhio9e90ns9u zC;fUH)x?U;h$=F@C8V}ctc0SK-8JbAqLhH4YSX5?d z&ZZTv9v$4earOGK9@a>J0TNV1uO%b^M3tbJv32)b;OI65 z1yC4}R_#ULAAkAa@wtdiCHn!|zx@84yH}u&O5tr*p{j(th(NXU6WFak_0>C}Vje{s zG}I)evbW=9S=c=oR5cMTY)o|0BeHmi@_^ljK~Y`pC_^PjTmpbg0PrO-z)GWS{T8FK zI04cuxreFdOie?z%H>;jz8`&=Fif&UC?txZjJs=6AOa4tjC3CO^dUd~!y~Vc8qc-P zzj^!k#~&O_9f?^x>nvHK8gGwZ+bBT$hG5ycAN)$Km)8#T`v8FbCjdhT9vAvcGHP1@ zK(Tt+-JV$i0Q-sgj7Z_HCI0iZ0|4Gvh`p3nIc$Tbrp0fDp&|l_P8YxEaZ|^=cu}W1 z)BzYWa6ZS`91k9!pPsj;vsn(fSct+B)N;<+_rermpaGS{t!tCxX?1*jczA#+p&)Mw zlpX)VqLEQMImEaweK`Ptg;*4anZeNPAMMWpTWCWeK-awQ0RTv7Spu#E?krwn+H$NToOeC;?NK+S_rBdo|Q}r@m9i*52iP+9^SoKA51W*x=w!rtSAFT zX!j+R=3&v9WkC}~7L^^SR4hCeM)+)Zj`;clfK9=|V;O6v(_TVmQaG}xGfwzaFqZG??Wz`Y^CuAIW&VbnsD6ojj^YBR;h_vXL5 z|8mZ%N-5>D@4S2c+qbLZ3do^-ao*z}@nDfX=VGtFi{dKcK~aUmJvwbf1{h{G40_8p zze({iW?;2dAbmdweIJA)^X$vZB>;HM&xdOvzHg{Uv62c#vee|&w79W2)`~B0ixg@O&&FOb<9slIR!-+$@6%&t$0K4&7 z*Sa1HlWhycD@(u^c2>6S0BpNvU5^<%x3XU6JKL{cGA{IS(XQ3vmm(9tavL3L(lxs0 zCvXb*<6l5s7+qu3W9WtOR}(e?h{3?c1OUY|kW%aph@j9*3xZIJ zl|AA9hlD{iL?HyC8!WKEra`!K@w6lG+pi{fFE+x*8t8*Od~k3=?XE1ROX2zxomv7F zE@%UfpW&?WmuJn%teG`!4)hIvDeDRB6^vD(Aib%Thm~ACoZPu~bmK~zre3C02Cao* z;fmwoD`jC5R%9DSQ{R5`e6=sM_;S7m=i8Uo0+QV-KzkB?@VdLd>#NBTu~3fc%$P;G zas9qUKep3>wuM=e>mHOlpu#JeOO6Zu6}eAteGqJ10^WFI3fstaN|s>|567f_5%|r+ z`Nw~Ff%@3pD``HsQT_PCD@Oupv#N|N5yqHfQHsk#Itzo-Nbfk-#q`K|GO5mHvwAYg z5oVU13So}A)_K!RrghUcDJ9PZHt6WN*qzv4{hgdk0Pufk9PE+-WTdS*8bKp~lx*I_ zWTFtvW?oqt#cx~bTWS;)ukaF}2F4DcGECLG>&jC(g_^X<5UF0w@ejXw@?_2#s!H`R z=0E)4*0*j$GfhhpS8}Ul!43d`{m7c@J^=ei?N|C`M+(_xt{0ZrPuAh(VletxlFciZ8LUin}+H=->atc=wmsOS>jgulpGybf>S%dqJ zXJ@naywskm5JjL3+Vm(BbO${t44uyab8}dyql3xO;q=Pk!E}PU!lZ%)**Q+mXQyYgle5`* zleL~Y%%Ki-3ZYOHhO(6oV|4myhm-2+(e(E9$rhlN}LQZ2q%kYUouI8`Hb~Ma&FC(;R+qIsfQde zikp`R;1U45X2GHC5w$SJi+2Q}8Way`!ucFk4HalW5xuGVNH=>;276&@jX}?QArQ0# zAqq(DefY{|a#(tNj=%r--YJgSNYdu(2l|t59pAZv6l8AdYSOk@RQKf1>{bEVy4@># zJ?#D}Z(lz0?mq!|r5kJm0Nw8@7XpTLkg&b_$Bw%FPjmfurLe%(u?QD>n@i@hqvC&D z_bnDuNkJ1Cs;b%zzkwifpwpDhcyWejFK5qBPUlVA&`w=Ls=h)h>5p0%=Y12j%y5#~ z#N;r=(WE*$JUELDFuZV%TIm#(A##*vK~IZCzq1q~g-X!a=Ev}vbZ-p{w^OWJ0r%3R1G_N<&aUO2iA*#Z^A@ui z&u6n)=981Ab;<7Js9h?^UM8-Jkshcb*E~O{^zdMMd@#Lwba3?$h1Q=N)P`im-kD%P zyLRm8u+b%OIaKhAFi0-*DOOniqtHI=JA6GU2N%ZSL7>XNz-pjZ-=r9$fGg1R)NFQpD;x<>?&%`0MA-W`x$Y#<89M z`SENEDZsO8~Gp04NZ6foPpBmIBB`<9P7+JWZ-QH&Ycz+zbGg8je_S z)Ygib#h`g{s<0Eg?P$Bqgu2!Dp7W=_est!w*hGAOrE31_$9E0|hY8WTsm7*}<*tYp zdv#(qQh;75h+{vavDa(8>}uhQ48lRXWtBHSGPlHHz1SaM-3V;cUHGPY|6jZ6A1lCk zr@-xaHD9o*)Vd_tG7Jrf;^x&0J_h@RIu%8Bj#)}3o&iv)e)3fuLM~Ehh z1j^d1hG<8_FH-u5DO|%D9qGKTY&uDkWRp5g>vS+l2UARHn08j)5{uWlgfT~Ti!Qm* zcJ%+nU$u`fT;L%ot|8snptzb0P5zb6!y+oNFW-kMe>lFzbrhF79PB|~C zKc6-8?6bCQas&ZY5p}Uv>P#WSL{tEJ5#&iG^vrP3tCS88riX{qqe;4TRSqYp6lF!A zDETnQBo)?vl#^XeS;h`Sfw$iN9ALnrJt^z5{#>9iEP6MxK2!SB0szamSqAE_GCg=z z&JXq*8jmdtUX^0B{`*F|-+hAc@EQgqusvH>+pbY;W~*+SHp}u3z(oLq6c$Z0*YvW% zKYa4=`Mjb{qn#h7_OHHw`{p608s5y)^>|tU}X6mp#%y5U_Sv^ytn|V zszGT9R5;K0;=%J|lRLMjwLuAEz_W>l)659Lk|~Rf6T#)>5&*mg04U4l5afPcS5eLh zMF{2&7yR_+_fO|-h}^h3`M2M>T`dEETBH2z>0K;fvNA>A$nV14w9p?a3kyQwpdkWFNExYHlt}UZ_u{Uy0?y#xzF2Z+qTVZ z5UMJgI@1OsqHFZ48p)7iymXmU7(Nk^I*K7o#z6p>Vt zN!w&CI|k7k)0P!p9d%rmhe=E8CDMB}^wff72Lp_hokruRKUEc=*8>3ii~{y#3hg5r zF^-lO-hQ|I^hz??KMf79VJqi{+_1d6GyZQJENt61+XnzF?-UWq8em5J>{rhoKAXc1 zRb+zMx8J+=(cS96;PY8kRZT`p7G0-Ouq-juuk7{>RSfe<0xW*vh2a?jAPTJqArNhd zFns*`Cl8*yAkx*N!=HTrCK?=1paBW0N{-GOanYcl6zQ@{0B{KaULOFIl`%oa0svU~ zKN1?^!BhOx$G;Dogy~Tg|K~q@XTrhHazzABmL&fK3mL2NIk8as7zfk>D8vJ10(gY0 z*xZ%1eEjMBm-nAVngTH9_pa7I{^-h)l$HF8gX}sE**_?+_yByFaKBsB8QaZhE|4rgiL?$tB;S~31b8d3Z*;{82v!*Re)xD>o7RjR)*2|vu zo7pi8TA&cs;&V~3kh{;TB-6>{XgWDMsE-fQ@gWW;z2!pTN(I2sZWqFQ(0%IIl;Q3o zqN)NFC}XY9vdSatRu?$dVCn`bgLTnjK7ci$du{@sUwaI&9)HLU#pPSY0C<(@@%}WS zjR4?6`RP{z0GrG1;QrmK9oK&u0DzGUC`IN09^j|HeEIp~bJ$b?_1X76xcTATbO30y z$t;3|_J);!toRUg;Cf;5J)-Lrm7x-ZirxSv7_Jb5MOYXb!GHP3-<`IkOp@TwzW?6c zYpBXTP=rW_AfgwER*G=gR=NZLmjK|k0e}b)iwOXYFbY~A63#OI;pg{Hn>3>$rT1?g z{^bWY?bghe`J!A}x~Gh_BNjfu=y%v5!O;Rs!5%`1D04BWqHXfzAf0EN13&xC(=VRS zA{~>g+S!jkyz|W)a$rC#UdDxG`oh6z@2#v?RRMaXK>TI_z)A$)hx4=k*YN~kX&kH< zFKMl?1GYRhzdZZ@Yg+nub}txPXK}+oVDt(0?(qXdeo>|^S?Vr5QsMEdcKx`X)*p;T zS6zA*NOW7RB#PRdDAW+q?YRPQ0)PkR8PCsg*5uQ(+4*cfZ`@@LInuPkW6uago&6?g=G;pLrauNLX&`iWb1IL%n8Cu*y&sI zWeVAi`1_~SUV*3Z-DIR+ApL$oY0tSJgi&-LB`G51K0 zbH9RkRqgH-R|sD1zu1EXbYaF>;r8s90KAe4&`Q^7qn7m6hS953{HuT3+HB%F0N92% z2yv0t@XLGocMo5lwrZv|XLqku|K_{b4-M*ohnYsEnPhKOi!-;b+hYu_?L&O%cb&un zomQDJYfwT!r5KaDhG7Q$>^ILJJUh)YNh&uF^)J4EeG;gMRHUD^M3x|;NP=2uuUB>n z04@Q*m#8@e6pWc_8bB!Kz!xv@(~mzxeI*m7{C7XTb~j-X>kDJ+OZOb@2P^}C(gBoM z+WPCgheajlFf|5D5J4In)fX8*{nf(P*w+UH3oH#844Y&bCu~rCS@p5%4?I<2#7NVEUB?DcpE+P*AWA}`Xbgs@AdVD0Iy^hfG;Q8zpAyPx zz~<_IBl~nCFc9g?^hR8_4RRCGUF)JV!>14PuRnW!I+y9uaeMyq=3)HhkM0~LsJBUs zA%K{|bFPwlu&@4?6zxXhY{P#B=}$2hpFvW>H44{42yq3H3*7MJ9RKj~y;Ig6I5vFm zgIgcoMisDt^h#wRK`C#r3KL7%+gp`0=xN{|{^h~ROrVpT^T9NI z_k*h+-M}PrZl$g)0v=QgG6oCPZvNi8X38>WZXc`mS8C_KYNpw?bhRE&cUR0?J2YDw z1e-f6o7^4tlxx`d%$w#CTRRpz>|n6pI@;F*1B|MSZC%@q-|q7Fve`##>j}A5><#$+ z6+Z2SgZ=W`DgFb}^JbmM89AeIJUg2=ft>TaY1`J@>^bM`5xq|*d-Jh^Fk3jF8VRss zq}6rkiz6~=%G{nyeRvj!QCS<9Y8*A^DV&8)0~J$e?wq-VfD5A72n2Is=%QEbRzO9< zb5c#os$_Ll)m1&I>pJ16mPsXbEh#MueUUXikp5%WHzV7}^`m8vn{PN!_B!5V6*+v( zlj5eH&hj<79+OAxpf@%g*l=GC>Q{SYrR|4W0RZc7?KR7+O^aJQROZ$L{`&2E>M3!N zc-@#66e1w4XntvN`6%m1ky3Kc5K&Wab0y{z`{V`w<@ZnKr<~eUoB53+`^opN9Zz7= zD7>@Lqm=?>Brv-31Y7##Wz4MW=nq5Fgd(EZDM{une)I78Pwzd`=?$C9^@;tvpBzqs z8Whhe6&BWq82NQv`T$%4fG-j2%$Rc)B#3#p5FA0J;r9>yXMcDylUhs;rTzEcxpn6N zN(kdEh20RkdJh0Z2^CU21fJ4K#%adi{_I}XgR>koyK$(0`O%eQX|EksXD`E4&Cuj# zGO2RTz3)b6IYd|qeS%jAxUUqQUljvvYqxA91-&YnXJb;oV00j>h+*G>?1t)po8iD- z0AS@4SozQ{&)ctXL>_6CgqRzm=jDy*KC?N3XfxW3CL_1u9!_tONwAf0wBb4EAx=;j z;mldY!MPq>y?;81`Va}$p5Nh2o3ls>lGJKZB|?OiK$VuLDixB7)YD1?$t4+c(qZ$R_fO)*7~pHOS2?M}D17fpI_*8Z2@+@IkXO+6KS^zOkAzA4rs zMHZTj@q=`V$Cm)$5&(RuBTx-(+nUy$1FTBU8C5!W{KF@YAD%NqE9HCF5B};qht|O4 zEdqd%!;bbHxUw-QnIg$%f%{MK&%e3nY0`*EG`A1%=ij?C0S*o7fuNeTQ6^;^9DdmC z*KGg**iW1Pwe=Y;eAgS>Fk3q)uNZ)2Z^?xf0I*wW^rG@^&`4gt`)1XG9URkrHX<%B zfBbd-rth(Hv|r*=C}OmQ3|%^0qDbPfJCldqXGd3*t00K8jKBTl>}9sh_Qrw!hmQ`A z5@={LR613kg)f0Z3h@!}(W!u5x=)Mm;KGL!=t;<`BcHO%>i=s80JgOu_S_BFzwF+Z z0u+3u`@Sgv@JfxR9RNVFZ(8ZB?{)%!HDfO+KIDqfuKlM)y}+5xgyE{@Od)ta$3Oq} z(SvhJHA%UdBLDcKckf)oVS+c=th5e$H8P0~0Ca7{Hxm!XAC#(|5x=-U`_;YY!NW@A zxN83D$2X4z2S$3V$U=11<(Jj}&Wm1t1iOzz_eiORDvOm48L&{?dm7KqT1%DE4UTS0-YKW;6jQ5gM)x5kM2HA}YtbH}S3aZY4IAIG~SD^3Q(tqygp*HBHI9 z4T+KefGqO`_lROEj^!6ts2dH>H$KQd0d#!d-QMZ@eySoH-{6jyy~DzNpC-^N5&Cz3 zP?y#J*RrT084(OOFfkG$k>$sn6;Y%6sRA`o^*;ybe~1HON=^e);s}@oYKJy4iI@UB zf2qw`@|mALp8%;v9hd|s#3XQ}I1(HJhrj_jaZUo0z?7J<`*%{F6i9>xHKOeGnnV&e zgh{x2L`1}jCFDv{8E3)KDQBcGwPAT+#F@P{HH|h^=UrF95$#nGa z)PMflN3CJz$Pp>2kkV}c%b;qaor{pXQce6y@o0B7|Jn&=PYllonQ-WO%j*!TRjGXDOQAk~0 zzQ(9vW8wSS{hHnRXyulhQxdV~k3v*-UB2W8@_Lc~m7ZNiw4{Dfic(~lGghjR-9y6U z#SFi@e}3=TOea?o)N=FTyEi^~r%pnZ5QOPMJ6XEL<~4i_{QBE zB8?+A)Q3;c&Ko!pA|c}@W|>P|3IJUKfG-!a%u;m5K;k3?EphL0eld4i9iERT>BD!9 zEyFgEXWn#~u(F@V-dRB0DL^-Cer%CNEi*km&7b`K*<9eJ6lf9a>XrN0 zYx3H50{WhX?4I@X4kYlZ;cw4ZwJlz+%oFwpJg?MM+8-&s#)o_f7`{%m5&KI40%pmo zq{&nbtG5X<(ET`=NjFhdH5K%BT0#dMk%hr%Y zs@a8w2r$U#o>zu=RF1u}#jFAqF@+h_ptADTF2CzP*@}NR^nL9L_?0?17mjv!r6GRV zPh(rU*_hE@4_fTF3mGc3u4Ugf5{hdeouXBSo@M<0;rtJeUm#80L-YKDTUWpJ-ejVf z7?~SW$vGA+0P&8BJaY4Wv+;1D$V6!1otvpnuIkY0WaeKy%C3k`Ep5qmlaR{+z$F0q z(p7g9F-lOZ1s=b|{ikQ4HAHL0`?s!LnLsiPM8w;DB?2#So75s3Uda1os-y_#iUNU& z;@j^Y-#tE%_G~(-XP)joYk%{gIU{BPwKDiqL8k}6PDqzuhBLRLxPMh3@!IU5*RG6L zBJY>4$iE~ocpDv{%ga{?02Jm-_Q;Ox@BrEM@5&B|7E%B1M@39U8Kg5DgQ1sEjb@<< z$*7ekfrP^Y*_j>LnOlgUGMiQtgE5D=NQgw4rXZLQoh&`E67ea`O-c%>!+i8Dx z9GT*EXim(5-#zqCpPoj2Ac6|*?JL!{-=7wypLo<((Zk9Dn-G#qJ%XN4^acUIAjH)1 zAxQ+4IH>UM?W;*$)mpbN9-p3^7t0V6im+2rFH_OWaB~R&zSJdxrfRC$Lktb^@Ohlf z!zo}KR`UMs3N2EvE&g_cEV@a50lV7i{Gr%b2Hcs0tqIj}rN)oHeeJkLwC8G*xmUk` z@Zxt5bJS>9^gjhq6&szjd=00=eV8y`9s|7I$n`a7_q$%nf~KJ(Z->|T+4fE^V3rdAys%gP5j0?H>U}c3Q=gcYHvB5kXlA znY&p42!;@eI7;}pKe%)Kz?fS~I?eiembg zEPtUaDHk2(ruzXyL1p z=QY-aH)fV?SMDbN$ljN&HwJd+pryZ-&qAd?mrw|GwC{m8e~8eWRy=f;&KJ}5&(SZ%Vd&r>#EwcXab*o zF{dVy6T?*fotsh#X0u$^#!j>EEgy0nb5BZRueTN=(R<(lAcdiT0oSJZ-ZyScjfkA8 zX~Xo}FCITRgB|Hv%ap)?LF-gIW2|Acn-a5UOXDrISav-uUpZC0sb0>jzUy(?D}3(K zSl#ELF2#TTR3JfDP?K2vbaumK+T9y!F1&OW+Mz;7@Ae@WnOF?I0)1Xx2xfFQUNCxx zJ)qBv`r|VwxE1A;l{Vv9{ z0<|g#DAkbLMoe9WHJ_|yAYlA0mUuOFuGe&gn~!$J{uWsDTC+4Bq0+W=aRMBaKh zOs19KQ1R~d>6ODO`@A7N)n7c05M*{%@nqDdhRg7C2>`y_C8C>OmqYyFlPB|LX5y94 zKD>M5sDcF4EN?LueJk|~DZtth#29d)Jr1uT(egf8Q z8TEb2vW8Tiy}X{$t78A!Rx7y6%jIRi3V0vg%so@dtIE*5rOUP#`Km+j9dGlMz5x$d zRXe@)#ww#|7Cd}`pa1U3X=ods;0I{G_uj2rSCJIm={^)U(pCQ=A}BP33S43lzvY<7 zcGgsZ1I0%lT&pZoeAc%2A3b?-Huxk4A~YcRasqG(0KVMe=AMfL5P@~q zC-T89K}LAgwKS~{lJWI6;B}~BBKhta~<;Qg5c<6bE_z3NN)k`1SA@^+Wi|DSl(1dQtI zEhB!8Zmqo==29DVAoX)__@WdB(c*#1j^Ge#x`f_82YY=zU3y6?uZkCJyj))XL~8ms z`+%?AaQTvnlool#yAPLQ+lG*v$^XI1&NYd;s zqQx5QGWuKsfG>5ak^*#xDg>NpNb};@^0(f(X^u)@BH3w4rRW8{?JtbfrKw_>)GMGu zDT{)@FnwpGs0GsEn|I{<-@H|0PS5r9@Wr|R;x~_8wm1t&jS#4%hIuAZs&Ji z&;{Sfpxx1HeAl z69^7&Aw7?%=kH>TYr%f2&p9#*fske5FXd`ciiQlYXXRDtN<6r{{0U%yHyggTkx2Iv zb6X4Mdc88F|n^=^P_nzK(5YT&~= zR}SL5ZcmRVT4{F!LPXS5^)dim0)Q`xH_1q5q9E}8JIC+cxpC{b`u6)b-@93B00C8r zZi(|>gY(*{aV%p1fl1-)a)j6iYwX}*1!?io`;!mfy^)Z^BV_XI%zyry$LGW>FgZlq zwz5ZyWm9n37MoVVz$-p%|JYk1@@r(N4R_oWTV?B&uhWA1GMv;mB9nJ{`6`E?<#K;f z5^$jB_wzt3W`SK7pk(WwvM;gR5bGBN`%1sR3yU&f!2tavU~%IuKVAPH!-~SC3diN; zH3I(5;I>~l<+iQj)$8T02hvxYV0StY*uE0IlAB&FDTs&)RbUCHbF_w^{p!(^vo@si zHdpWu@810Od$nfN3S(IJX^t0qWVCd&Rs+1{bVPuv^Bk=Qh+6Rejr8q1SMMHH-?(-C zz1v3uy1Q!fAOvx#JaFOh`T2=oehNDP0HP&Ss(S9o0JP)ICV>fZ3o)qCJsVWs{t|GR z(f1I1k(n0>SBgX*C=f!NCMrqi?yVSW{L`;b?mwNioVZ6~bL&d_$#-s4?$@T~O)F}9 zo;8C|$;L>uMK>8AY2TmuJ?F!{G1OI7UUZd99<1U4Iwts%XN)PU$r;zpZG6?d%k$m)O$)Y>q zxc6OF^XWwdDyjrEoiu2Y{QW17pPXf;DV%lWZ{5A|qYq6p5}@#8(zY!uaTI3?`slTa zdf5R1rK|ZVNINcnaua~A zRkk9UQDm%_nrGcLmXfHp8Fd8{)QSp7036{^?VRe$sqwE{maVB=4f$CXsn?Q1={cK7 ztuV*;zHxl#+SJ=KOUbo*_;UU)zkT9nnGj9ApvUG|Bfaeaz<$FJJ9NjsJR5kM{rZJb zvFM)dr^x@Bum4MY-Fzj}w9Ct%a%Hvr%b1B!zRO*Ms53TwX4#|1W#|zbb5AQfl5RioeIg?n*w|r?OwB4aq*q zNY+Z#_4d>H=Fix?Q)KkUu{x0}6+=tTDVWH={O0+SlZ;db56RO7$~CVAd*tB5`q?sOPedh zO91dSul`GaT$G}qAlUtM-dNHf=>c)kmI$G$M`i`8vj}hWI=t)!Hx!h3`%tr2?-+7= z_cBWVpPdl>BIdMgvN$XWG&!n_1K`IW9p5|(ld}+YJ$mqhzq!|(X0$3TvnBx4E+U0# zkt-hfzElL^f$+`(aMM;|H;`D@#ox>h+V-m%0_&=U{m=Sxfj~5f z_3K~h`|T6SW_i0o8GVK1I;7H9^QgbPe9bWcH|ML4^uIW{s5!)eBKsp2A|S!+AtJ#R z8Z8(CsF2>q3>H*`BT<o!a zU%;yRW}=f2jNV zH@Dq;+uE$HU)!~+tgOteOer;!6la1Y00M|`KWDH0aNHvT93+aOQXt^lU=j&p3_oX& zYp>N)Fn`Yun}DYU9X$D-xh}>tLyrzD&mV-$J^Phe2(pBmXF92XZWRYIik@;LO$sPC z*$euoiPf&*uh%9ww_4eXCoD0a8T&_Xjz>UnL;{ZNl!d&2DeQCvFdG7N9fuD8FZ7Fu zKj}ep3)z)IFbPnA3J_b_vjsQI<$q<&)JzO`q2s&I-FWi-$1JoS*ByYao7Q97=1|u; zLw298mWbv=Ci0B(?f~hvewIo1BGdL0+{yiqRPt~21G*s8@DcD2A6z`O93qmtD~zsh z?tFHAr;P}sQF4VUW_illLMY;(k9|wdXvM$JBjYK+%^$`C{h|red4OW~Pw}*%Z{P zN97=&;eqDY?@-Q)o=!3#M#bQv-wt*#6FEYs#`xu%XHM2yvPM4L&*}R1{wLq=PZG?k z#$AzCRoOwS%FMy3Y@Q8507B6n=1cPRFa6hl@~=O#9rYcr^Nana&o6K@>@p4Z14861 zGWKmnfo?bna#c%9jj|KAeDg4wY*w{9}v7hNzC4SO!_)I1cjQIjH*;9-}KB?6?*H{eD|v#+bdc z9|O!_v4tVqdq(Y7^5m!#H2sAEREWBa43Z{TtMKtVXHSe&S&DURtp4ie{VTVod#QX< z04X>L2FN6njNXe78*E0q`2x=L2hl-1zuMOilG*x>pW(&C%YJ0`|AW@u1{>_zF0Si& z&EjMT7Nb?Mim?g?WsD=#?B&U$rC{_Ao-iWBW4C z@Be@3XMJa2dghMV;@kIasy&@QG$5D^unun2_vaayEU>{oFzdkAL%`R5SlAne zJ0#@naOB33uzqr7rRIrIJb&gS1$hVcwLp@+;Lm*1UmQm0gZ=Cd_Lcjw`C@NRR0Yra zIY|U6P^J>v2{*ar2&hXch4vu_Gv+?KiVO8jD0Oc#_Rz?!!7{#{#Pvk0nUxc+r5)_-hFVop6XkSSTq$ zX2Ky*vCw7o%OZ;$pNIXR^aTK|s0Op2?tv~Ay%dp7w8Z@h{_@q`hm9qL;8-$$Yh&&8 zbJa?P0EcpR&!MahhHjqVJs~N7Xw&)!U9-9GdnWH2`BJ|AlV5OgANNO;E>DMbF7Eq& z@0XP{)C_|?PjdrMA^%~&50ELq;2{FcVqhk6z)r%09nbFPPQ+CrWLQ*2_CU{w3ZY{a zKsIRxf*gu~!G1^w>ScfKOr1_o^_kzToBm_sk)#96anFa+{PCLc-$_eKE9hK7)Jy{$ zkd&)fDOnO4;>w-$#mxteA{Zm`i8}rA-SyMUh=K`$$hmYzokclFTF?#uvA@nsEvL#{ zv)`X!;Xwk6RHDzBN!^mOqa`i!seOGzN5^r{i>M2f&jH~>0dNcofS&%Y1GH$)tK%Yl zk!4>Cj%T5%_A30o5JbXMu5?lL3j#dn7=z#$<^fy#_{*0Mwx-I}ayy+aq4~vYXWuwu zwWV0AZJS_~yE$XoB^zSYJzK0F^2YAC5DF86=fe!YfHNZ>{ zxD)^-3YrHxI;f8+8FR!m!HDG=HUV5PIXw|_REXu83+SPc$1y+-_A^!jPy2K}_J`;B z`aeZO>L<_4p3DEUpxz5(mlLuqHiV)g6x(bq@y)&b#mxs3)-YC@oLu6s-d;a7MwMZ% zu9_kN7;+&JlDatm?7ifls{ojD5|_Pax01$@GV3xPIFSvdh%q5ZCK2if937ftmfaP- z^Wr>@)%84%IsjPQlAD(V=qE&$VTqU+rUrj{2jW$!?;t~A2cMTS1PoS%ERhIsOE@*c zuiiR+s%CB`R@KSsFK^uYc5}aFoVFfAq!?fYQ787$B0X;{^u3tK?_lYFAusWhetkSO zp?^knd9JT8r2B)toExuaTm}L_2p|!R$QcRah$#3Ja0H{|MHnx}c<|GQ!#UWC`^g{4 zi16L{`pfW~PeE$4Kj*>`@K7zk|J4LeNbq`S`sniU0)U9*q%hSruRcDalEqjMLrc z+}N|3`aq;N*iRX!eq@cYNICu3KbhHc#*10>k?>RWjGlkn_*tKIAV@*-R96L)>#!`P z;324P8mYopck*Aq-Q2fIl?88>*?#=a`I8l5Mue0zKnvzr0M3_AIQpf4!?d)femkM( zZYR6NU!`^$0+Y80NQSK`{^QfTfByX5=5|(q0^FE7g0mjP!N+CGkD(ny9f0{Pdrl@x zEiI2yYC{#LEn3IluH3t~*F2o0o44<_Es7$Z`>U9ok+P2bbiscW#geXt;Mt@|jELWT zv{CtGu)nX1I&WmmA*HaXZN(Y#b2PVitaAqes1AMzjK`473G z;&=R1PYsc?j~Ug~-rha~7}&N=6)R)yigBg>>qG_`;l{o6XzDGiN%BYg`#Jk!2jNxP$P~?uGIk3+9YD_D zktmxna-hhFn1_HfWBk*H8_QMOrU^KD)Ks6}-u>csyW1jERhu2aay?>lS7rc+7o!1v zwIb8=2alKa3l5;5!Cu(0^;y0Fy#z=vpQAo-vS9vICV~t8u!H{3i8k;g@_Mkr{w@yf zJKd?iySwxq3H0CJ{eE}6c!4icKF>YZbH8^r=-7|^oaG}Yb6k1gGw)XCB zH6DGa=u|r!L(H4R%PK3RToAB(tHrU_4sqr)|`R_W8FV@+4>8+gaP^f$X37x0h_R_qQ$N(|RCXTE3+E2f| zGi6ncM)b5|{txe-TVFwFJ$ACb^Uv*a{kafWM+ZDxCpg6CssEqMUBN7vl_Yw-LZc=ucr?qjlvAz}p zT%gUB$zA!CP`Y2-aqcD^P$3EF(EJb#PC7dsjc8Jn&Mo5~-#@n!a!@NW#nD%Hw?4bQ z-x6k+OhJoc$c`s_D=#Ls_(MJT4^{j6-tYcAE4hWb=c%&uFR_vtgaQWp(Ph9wi0qKA znlW9dTzkxR`HR|L8HdtlT@q@>l{WL7gAMjG76yyi^Y^B0FV`V|uMp%9`T>4;h{V%& z%;Z#(EL15W0j=Sid+CeYTd5wkN)^({=)ZdN^!hStAwY7t;Dyt>PoL*yKW0m{;-TdH zJ)i+RfA(gqJX2W#h{V}baj7j8z7yFWYMK7_w-3H<6id!!CXeitMlohBnR zt}G^;tnj-J&o0N*HoKv&cRgIav;FnW>0XA_5J5(X&(l_U9@xx_eVwPi|Ajj@PgVkd z|3EmT{)7F<2wCd<89`S+0Vo8=>eUZ1qs5p{4Q5Cn2{Q=La2^NyiF5rQ3eo8uHJ)b< z@Nzxs4-x}@ftu$#!`LWapYc^?QY+`r(0rud=pZJp3zPd~WGL2hswOi2c;)VQ>9no6yJYU#$E&HWZI zSm@ZdpVR<5p6JG71_A2p+m0@)4{#K!s%kT?-r7&0R){&LlS}c!nWvP52X+7sds|FG z2D6w&?{?>5f`PFJfr$cTLUe>oxIv8Y5pa5hkKa9ea*3Xs5SJ%D`ts)H)y>I7XdRFl z;!}^%cS~G7l@jOg-%NUe8sMqRST8Ha45|NMKiAS{+w7?G3mj*rY-<(&Z z7ZNU|0(Gd{8-pE{8sNuAx*wFazr;P@7n^whkdUWmeHRL5QKADaaOF;0`2Iaqmh#D( z|M1?q^;J}W!A&Lw1Y}cY%E$mH3yS7OH_Cck5DIq)Wrwc@=w_FIMAxj13)V4K7U;5! z2yC2=t4p=RP>uH+zjbHY=GkW+P$fFfbIs#b00?k%Q6@7F>>FTCCSgBeb7yaww5##^7FMY^>m;x7SB( zkQ-V^VSMHG_LbX{21p&iSnS)1!eiLt1BhhJQjq89fBY0t=Vf?*;kl7TL-s$|&vD-u zsChXj&CqA}9ztHPpJ}#U)4%sjc80_%0X`TqXda>{F)3tAY&^y_6 zw+lFVvFa`t)&bED5N``;vwL7``1)4!&F!rgm7t(@C8m$xUEf$kNY0$(p0i>E4Pv3K z18EDSb|7KoA+PiKjKa?y$`q#XwWqu`t}dRjkYIu&>xH8w1&cX3W}F&_ zJ1DC3`%5)1T1Wm5hfB^<=$AcuDw})s#GD;QtAsk^>rvNKw zKfvrK{7D57hwOi_m*(P1k4!!6hb5u6HhBoOQjP{w6AX#iC$=*%hNrf+1T+lF0xWh7 z0ny4=?Se%Z#sGu;6djo#7Sx_e?s-6u>K`eqy7BGR>LJBR6aR9YX147jng$kLPaw~B3fV; zJi7@2!DJF#%Jd8&6#cMXM_dIXedxO53y+QLqEtdsri`uN?6WmS)|myIWfG7Nhy9`0eM#iJ=gsNnAM)PFI#ig7*FrQlD$M2qBF^u3rGx|Sd$V)OKEW~{Xf=Ok}6wBxn~ zeB5Y2Eu;_>WSY7xLV!EnaOKwaZcBL>F{}oiI{^Y_c7)HvQ}Sssc!(Z~Ybab{fj%~z z9^;p9oHaOyslGe1ALy=%r(RhLzvJegYTQ5&3suAQ2)3Iot)378v5R)s=5|{`TeO=U?9a z>YE2m0$9a3GDbOaUMwgMEC~-hpo9ILqtcIWsl4zP{BGKbXMN;T87aP_bv6GmA-I`? zkl;?wCT9?0%B-p}MbMg@!#M6`>@@iD+v&~CcG9>Zk3)NMls|fB{nQv^L!8}Rz~N$s zJx<$kv|~^Z-aHdGR+i;WOW(=q>zj|9a1jh5GQ3ckQ$~jg!~g+0($Ntuf{a0#sr1Vd zv9*U=5BC(u-sVc_we?dg5rIf49S4WI_&0M4Fze_5-H}a-^3dYc2p_+D=EO*WZKy&D z^_4r@pI&*eo3P&^R)J({o2m-gQ-Kf2h$60Dq=)%~mx{=rv`79uRGvSD9L$jS5B3vY z=NVVVL0yt=g~>vb9iejLy{(e1O9)ozgsZ+F)8~G z^xk^j?%0d&j6K)KX>r@0-Kd;BFo*_*vXxJUYE(U%qA~pS>&+{-ww+5=tRmXA+JE`Z z*^?u%RTSuwXTV{i6>txaN$Ni<0G1Nozi>wF1Oci?x3?ZW*hLbu;I7#&1VA|G$L_`* zKM0ToIzU-vDZ_yV`1;2F)T@*^3OqHAub*Scpu3qJhp0DqyM-CV0SM+Q>VOj6M?~#7 zwS*7fJ+nNL(Nl)v@~!Rkr!Vg3fc+`VLX5F#8w)0uPbWGyF?04*W8dcipXNq??AvU+ z;NY8{^oBp_7V;3|2m2`-9DT_wSU<||Q9(roliAUVZfHOoEysE#d9aA=n8|wB+#4+H z@DIKJ!G6jZ^fD6erxX%TcH*Dt%Ihb4d*+Y#q>@bcp049%Ml%>`MZeYp+mu5v5KTfd z{Ksea?(SyA{!%HOh98&+m{;oBEaDXYKNCHSXxf!Y|q^u+)=OpDVbR-ZthMtcOXL` zBZE2RvqyvD*5){FG@we}G7wH=64-C??VU+N3=AXs=_Q^!31!4+MoUdTUO|t5sUsqQ zgwjb24r`JNl$;r7mhkI$*U#0qtUMZx_F8RC^q*hc+itP755zjVn>;F;vPqobh!9<_J&t2m01%9pJXN8pLTFp;?&HDsmKlN)rn|p-Z#|TSa>9jY@ckcY3oJ?- zy=qw5Cqy-h0F0nATby0OKfHbN?2@tFud1j}ZB6~JUp?5(NCd2+LdViovJf1=6TEa^ z|5WhkkmV0{>{6dzzPFESh1{c~=Ym6ySbzj=PVNrK=$P@1%iX0;7J!Sfba-(GJBq14 zUdHahbAIb*wWSXJt7ccO=w1PqLDaV0;*%@)?(a2Wyh0jMb7G{A-Z_181Wg^uDbLwM zZMmcMnaTT_b%Tpz6%EzqCInQ%>+7r5?k`s%=lo!6YuX~E@(PnI*}proh6%vYwk9W# z9W)Nu-`^u!_$2Q=c=z(g7>EF=EFDA8s|fd=^IHa#6<(0=g6Ee4P*b7Bi3Z{ zMkBUaDaY#Ge){uQTelvf6|y2wm7b!Qg%sf7zzpDpO8W2P`FSzF{}Or)!-Qe5Ly`S= z$$LlR-PHh22<}F(7wt1M^!z~G;tw2jn!1n2K|P`Y05sU&-JZv<{_w?LeD_!TL5y?H z>FuBO0SDgiLiRdFAiG)y04fvtM!3C&KYxAi*7n4!aZ0I1zIERCy`5o=5?pItk%QrCtsU%r zSS#@YvOgwP_Wjiz8s$<2Zv z(WUC4*+1AJ@NXTQm15cE@5KHE-?}6 zSVRl3PGua>4e~l}&|w!~u%G>?A4Gn>F!JYTuLR1k(Ek{8_wgvU%@hDEW(&IsHy+^g z>zh-A?24ue?duz7-g~`@36&5eO2)2|&+4fy<`)*CiU!Xy%V!{D3t^gaHLfNt9_``& z!@aeYr4!3ss(_?Onjv{+bjhK}<3xCT%q9Ru*E}R;_xpCs`Y8-`58+fLK zcML-v4>*V!kJzl4*mVuNv;eN;*5chuOK+V&Gqya6A=CC{*EV9 zHf`L5b0_fj`Q?oh9M>omNgeqCK$r#{fFtfTQ0Oi|B1Ch^lUMSwYXs1{RF|}c8sL~Y z)_Jto!hg#KZ_jSlAqfJERlq2qN_gk|=)>1HYVCX4vnuQf*Y0io?dtxNXk`lZ-lS!y z205X@2Kx!CcX0M!^ncT0!hhgu3mw(qV|~Hyv*z1~-$!je*kC_zJ#aLlZ-x|Nk|lEA z@zu@!&#&K|AXpWtX5`afTt4^ig%}!CL>;>XuI&6c1v;R>B5I3S_Akt_f5*yVh$-b5 zgFIJ6o}dQU!h}KtYIth)_l8mvpa)aYV?tbQT}Iv$0yc(jK6e;o80JzkI#D>)6kTb)B=mS6AVX zxbmU9KiDf;16T+6;SBSS*-&$K&HIboD+hNlp?>p+bKb5VWI+x!z+i{E2Ka7!HGT_& zRZIa7rugfXov&|gv0fFZcCt3YNAGN0I*aI7iipZ|XE&o)VeM6s^6X3fdG@a&2h8J^ z(&U`$Sb6fLx?)xxz>GQqo}g@Wzy&uN0UkG+)MGdShyit=!AbxWU@((~0&Az2T^T4) zX0l^;VI*c*26I9F2!u>9FK^w80~3QIW(23VNv=_i0GvOC-@JEjeVI#9ZE7c0-+a{m z;fwn_2`(tZXy411eAt}zPz((A>bSVR;jccSFLYi9kvwyLpBdu6P+^xwp@4-{p)d>K zM^ktWHrUVW#t(n|g?k3>Pw$GHaJcL)M=acyok zNVjeacSg_E|5MSV$_#lF@9g1|s}Jt(Oj#{aRzpi@|KYter^gr@B6Dh6BSHvLm*(3QsBkvyKOd1mL0o&WI2f9%ac}j?}C8 zs!ssiEx9Ex1%MdD=rYOWj7%;VDfea?OMV|bF_*2Gn60vyq1JM1o{ffJGQv9ry<-^f zx-AJ~w;+Q>#4uF`0xS%>Zk~d-%q~UP2&h_&GEPMN_Pq;d#|X_%RA#8}?faj;oqn^8 zeZouH?=UAb0w_t4Q_hZo9Hfy(iVNQBiMIoaDOCANi>qCHQ5%BgV6R}nQ?{JV)#Vhl z&V{LM+ZYS_H6_XcLj{DatiV=~0dn^&gR^LY%iur;ycY$xVIg?1SG@-KQU3q2SOb(m zH491IUr~x}S8$j}8q7+=%M4^zVt^}KAZY+$Yz%*UfInQ{y0hzGUFW=%C+C;#cORTR zSrzcQ2Px1bWT*fXC{otHu`4+Ul-YfU>w}xJ9XsCZq%4D(xl2+A(OqDewr*i01d4Ju zI?Rtc(=Jtc#46CZZ=#z_QmOgxIZM8xag53JcJ)`HFeV5s|Z^qp>n=#B& znq#)ZqXlL3KF8=LnK8zqct^$D=4Q7o~wRtqgilNHDv;_v%1C z!+G96+^fNcy#MiLQ<;1BUE;+cWw0W{+b;%`#ym`@EVk2Rah!+=@Yk;&eR1RdehT9- z9wi@n^WN*{-hOQ<8X}R~To&_ZL2+Qy{vkb)h4ijtNRk-^&)HpIqz5KLB0LAU!7Q7S zl8mHLEQMF;JC^e9Xt={yegZ(1<}uhm-7@-ePmq*z+q!5r3RB|YK6WO^f>mfeXG!p! zbGJ4;k9x!Pdfozn-NgXuqIcq>5OVg$gQIr5aX$X?ol7TbGPhRM`yQ{}+x_>?9_)c_ zsKiiLF~|)P;h@6=quk)n)#5lXVI6F+W1OF$M*e>=q%Ap{{!)@uLtdmE=J)Q&(0kZCs8GN>0k1 z?00{~B+5xBz-dAiAb1Pq5N!~FaVvu2bf;-!j?TPjqKHS=CGu(4l z29$GZrY*WeUyC@t!M^XsSC9FTiNP(nS((YeQqBx4JmeK{dW7G;f96Cr9bw`rx2kv2 z_)lN&+&Xe^RAt7gnpqxGVxl{P) z-7{}oTwOoOXdoON^BK)-4s1rRia1o)gALjL6H(@=fr&GQ{ytj|j^Lmuur=Eyt~}6R zu552jD}|*%^U1@Fll=8BPMloAh^T-Fi>Pwz9c4KLmZkhTR(ZIR-{}tzRBM{o1@fG$ z5REo%aDQtjvD$b0_U^;YZ6p8|&`Y8isVvM(ugAaKPyqa#SF-2;%pZBb4qt^3E#%}h z?6^WGY_K0#13WE2wJl-!}pkzu8VIh0_8nUMx9kb7})4PfZ~4>s5jD;Elv9=rTqPBdl{!)G@ppMJafaH4D@ zNoe;jon8L;{WIf;QG~La5J@-#L&kLdx}TJ5x(+5U~VpE(J`m!rHP($2%&Yg!X)F`?fV|a zt%s&fZ@zgMK+f6Be+SgZY! zp+QwW;^=R;cK&j8vg>dl0?gUuPGnkkNpN9C@;|)hgAMk*xktgOqK{Q(a0h@8qTC@j zApqtG{b%If8RB3KF-IE=HrQZKjf3t0B)R*7VrEEf;Udq@wq=?cwjF=Dy7&3bM^mmU z)bMu1={uKB{qn67s}a#!R})MGJcFbQFbZYA4kgYK))xSSeP=^Nk9S1WKQA+r1mUeW zFQk-WT_@qj=7XJtCIJej5ER%hwx6@)O#8D7w768U7G6b+Zu`uxb3^v$v#k~U={9~}jav!}++YDf# zwgA=Zd_Se6=<9^x)g5fG#}g^D5G=+JVl*>>4!AHLhv_sU1jWkhYAfNBD_h@g?sK#@ zZF3zo;^du+r{23w^45dF@s4j{$NKqKl3>t|1U zJEfUdqpz+%RDcC2(})-?yXL#!I8y!MS6>LMpUcc*Uf=dISd|SAcX9jvc5)CNnJ=AR zSsL|OX~&qsg)leB-T$yxzwq}%bG`#nqLWTIrH;10=L%{Ytc;=k{MJVO&%b!>q)qGG zMA}}tK>TP<3cGU#gCoU%JQ9uooXUfL~#4K~=5nLo9-88w%{nQ2${D}-*y z22q9Dg1yG&6WC%6NQ1!!`!17|v$rYd?Ahhz4jGz8qj8vKO^Mr&@TV{D-rQ;%RjFy0 zLLT|{KYnoT?F%txjI01n0uVZ~|1RlFV(O~`>vFasQ+dwk!l$EkNP_@5fl1%D%CRkKej*rbg{;AQbEE z%-?@@ycqlhebm%!Q1#=#o!b7XG>W zObyNc!3O)TyY*NiWnG6$U2mBnU2tOqzFv%Lx9Sh zz#ppt1ZlWsgAG=0!#wT?kV_0Be z*H4TrySrPpxwXH&j~vhlj6of;fmA<%;SpIzJPJy{r2qgr;Txwrp@6C5>aFdTb(Rr+ z@$Bm9WkkS~BMZ+iC`vy6dgwKDh6dR4)&M*X}*?u$&Uq zwriE%Jh%4I8>_1U6+Ac7rP0`3#>Jw%(+uv)_%5Cv_ugtkh?)!3J9hbh9jF z0Swe<|Bt2oa|Mp!HVrn|vkUB5!T#{K!Rt^=jDa@uJku;wud7+%2ohpyg-9}chUAVQUd`%Ff+)c zAS?x3I(K?hRkTW>zPq(^`ysL+Dcgd&;@nvtiG1To2!VB7Xq&a}olL;j-`=T~R@;;V z^Xb*mi7_fA_IE4`-dFH5R+(YO{Ud7ZnTqm(>-QM^F6R`2<(yMi2xXs1k*I-ltN7iA z=T|}w-pX^EeK+&>f4lbOot>Qo1Xv^qxOZ4D%--gX_~2>|pL+inkV8XPbFd?H64#;T>R40GuH+WLjpw576Dx65OQ6w%jzV9S(NH?n_7RzJUH+2pQc!Ek+ZF z^3re56VA;|Hot^?pL8?Vqf93-P;v?@8iy{(M#bkauVkxkydG_C|>87d))MQ9tD0;`NRDdw=-!&US+au)20}X}lz{@PlR-gcyui_-=fn+w*v7 zGCa7!j^JGcgeOFTVpXSOsFE9Ec(8~6{Nmo#JKJ8bWmc6T`u^p!YrlAF zZEXafdPENPL>sNk?4Km}JP=P=#&8|TcDR_&R|7nIm1(h)U#6pHmvQOLN=0v)#;v|~ zYioOg7CjTtf%M`iy#BoLD8It#KSgsngCdD0IY%}juH9?@cI9EKI>s1${~v#`er^S$ z1`$dev}AwA=Y@q0#ToCC0Og#j(I^Ac3^!!p?gL!C_3-X)My#b&2amaV|F!d%PTNWV zS_CUBU7$HBd4|AH^;f&Qu207_Gk@0=a6QR}i`IlBcooj`D-*l!LPh|-Z9Uh2V)Ook{NNW6*p7`x-( znK5qy$T`NCpez~90A(-p<(9Z|*RR~Zzwbzl*%_%5b$b8J^)qW=!g54KH~1uzmO9i8t1(WgxouJsr9if zs|`}}FyZ)b*Ehes+3Y%Oge;J#D+Dt*0;~#E-x@I-tNU-hFWQ{-dkc@9phFDB}SXSVAX~IB#?fWV?eM$@^l~t=M*m zf6Wp+b%>xoVhBJ`WClbtV?g8HiX3q_VtD9T9sdA2xeayVR96o+`{p<=xz{o&H z+a`xB2Is=G0UbT%ked9=*{~2ZPL6Qy)KV2v@^+f?)th%)Aa@g&PTTaz;0IoL3=;)N z!H~0CWCrfu-dSgJ0ci%~$zW<&To6i*DZ?4R>ii)XK5FnMm<2vop`I6cNc ze{}lwvnwOGiO#sM@s-WyUq5+ta|dkz0-7e#QCUDCQk!o+l26uqn$LCzh}U38?P>`q z5*(cdAKB~HkVaBMb|8dIZ11;;wN-1o`<{C=9to13MfIvTbMvDiemU4+3s;8yH32)M z&B7usX7HS@0H(nm=0Nt;Hda}xcykAT{CfYZyZev4@=$yBaljiJYahR}c6J$Kp>jlr zw@onCb=9=(qR_@nF>|D2n=0Ph_dkEV{p|znW+=q+WL-?Tt1vkKE(eSC1K)X|02=HF)&M*p=-b^m z0`3yP(4{#U5pk)>!CBX1ScE&=VT9agv_J>L+>oIRcJv}r9}Id?ODx}@TMJRB zOd+y$Pa1G`KW~MN$3??tswaYk5CWmxP6K^Z0omzVuJOh?{YZ=ywNpa_Og*z=C+!C3T)JGeUA> z9JS7ALYojOxRT^m3{T7c2OI3D*_`c4Fww-qlRJ~H?-_U$3iWjG7P(PYz;1)Du1`O? zdhhOjnucJtAx+LK@i*_CdhZfyVH^;Y=^ji*q_%++V|4dfxjxvT@|!Y<4#yi8POL9i z%if+I)#pzxg^v1lmRlT=s_oTY0tP^nyIY7ZBo~8;m^MfW;}NQWoDtXtGBp_0G3Nov zdKk*Kj+={^uN4NPJiB`c1~5Bl1U+T9P`AKt#uwMq^{w5#6&Y5C{*sePjK^yBFiKp@IjKQmR7b*#XV8hCn*$1+CYY0Sn|q zgZ->WLPD^XXq+UP2}S``KmbS=bCEOc2}Y$?yhrltPXGkavcpBA(Ge1s4JRv%988EM zCOSe0IS(iN=PuZKf^zh%py-*Gvr8~qa51Z6=auKmNLO&IRrtjl@x2R6Cu3{vRAP?H zclPl6&o@86=@UbaXmSQEY`;#T_o zXZLRGbwHt*f1R#`gcS$oBK(T}U{58N_(GWPm zSzmoPrs}ear_~5uUe8(D!NPIDj%$3L{V!bHBg1|@*kJtu*TSFX z$GaX?MP;%{DY@LN6;ay+@sn@1zqo#X%8^1%VkKf()8D?g{^r@R8W2615+xH2L7$MWH><9gM~RjFS617!C@&|a)Tj8n7cK3DdSAUKYeie^6BO1Z56|Q zGu@Y6-)#Q!<-_au;1PQ*PyxX*e428v^ls>@Lb1~Z9-1zL9jyyCOD5Fw49@fUMeac^ z$nX&;aOjyDI-hlM2OI2&-4zsce+Tb&LO|0rAq3Bmf?M!_CgAEO{{7SKYj=IBN|f6D zI`6%4X7pcwwZ5^8B|uH~?O2-LSPIIQ@0gRO&O zHrSE6Uk4=Xd(PY0KRe(!;0imr?k?{a=NcWuv}~{=UjrN*50|%C)%%l{A+n0uu$S@W zjs3rVeQRf0WiAJ+n5QSI{NWpCKDb=35LJfd9Do=iY3_5%nE6Wn=phJpq>AuOzr<6}BTje+E}!pg5AEJVQmoCOmUG8AL*)wx zNSF;UNG2J{7-JPe0MKBWc=Ig&>BF<9Y9AvrXAAX2^|gDC{_W3qb~8Mp0j3EI2gW~( zJej;v>L$BUAy3b(#9zO+{`Q8AfZ9MHgkT}4aDK|1P`^F= zSbpEb^MnPKiebm*kYtpdzf=cEb`~xLsSE+;&hjXX0PH9{vHo!wirg=S;0S&4*k}J7 zRO}B+_TRa?dGlkA%`d)j298Y00hk$R8OTy{PdQhH;2Hy`M)>XfXJ0$Jx*U_I{lvKE z^{!Wc_{**9ci{#E_^ih=%Zz4~;$VXvA+w&%aGt_z9@!3hPAygrm}UP5>hX@DcbEVS zc4SLt5Ks^7_L7ZPW82wBOI*K)KYn`m?oN}!vNKlJj=BHlg_FO0=k(dt0`bW?weBfr zm#Y{8SfHkw!$o?1;ciQ3L6K%whYTpYpF5wVi{Ae&KmiJXl`2hh1);@)VJHB8wx?A( z{T=m%Nfe171(gDzZ*}zuzhTejXD`^OCu9Kn22AHGt((M&kXg@=KxQxz3W_z{r-2@_ z#=_i#iOzSUx3{~9dKvTAQ7Km+5PnEn|mH} zSZ**9{M2aj4{xn~^j2IMAt$5^k)etquoTcSm<59&8$>sGNkpg;$TZv0_jyOMyMRL| z`nmH-Wm7hlJrD|zQL$qT?`iKTyhXJb18Yw${_|qa969Eh>ndl`_;Sa z7tXBIOps@n+vwWuhkyO@&dvL&A(An%>;n+9xkHa4!&8-m5B7>Q>_HB8sI!@_zu1qe z7sY@}AEXr4oHs^hJ&{Go5Ig9?L8a8e2749GKcBAjz98Pj1%KYZQznFrm>61Oa}WRd z<>vLxZB$F3rDj?oom(6I(?=IBoD7%(Eh1rt>`FO1JUk z!Bl0ipa1+gHER?1R0;D3S5gIsIn8Mz)U5~1L`8Wp(Xp9F9SVTws=$&Zw`^kcvW^WW zvU#JHMo$VT)IQAc9JmYY8@yuEOLqiYcnD?pvm1g34a%=S{~hb<<{1&;3|%~z+3V;T zj4%l#2^OtjBZEaKWM^5{XMh9~YT{&te|SCq^3v+b&{p);)rRrI6#w(;y}w@D+i|pp z7R*i>vo|zvm7Oe%ZygUxnc;F)V>)KrCIdusutVL!LNd=Lb)x_g1;UEddN8;uC2Rr=TrH-JHkLUQw6CfBRxx1_170j)aX>_v& zmMU&#e0pQ@uYdb?bBY|7TNl#w%*a2wyzvQ|u~oz_*z8JWn2Nd_w>D66$7lJJi2DR726 z@4rwlcRCaR-ygze;x!BVYZ6tDMors}N0wSx01OIody?-w+`oB$vfscWL+tW5FSor< zIs3XyI1*EvSG`9w^L5nstradvrS#l}Gif3&q&-f#$)m7pxO9@g`Ng>tBh^79r>2RN^t^UZJW?AZ8ZQWQL(-8eb^^#|+kY%nwh^er(xNH>a_d+`1R#A0}T#uGf0aP=-%za8P$p53=6@7y%J?`$^K{iFmg9D z8QrraGe$F#lWB`sdBmRMi|dnXceWIllbbwO%oV3^UO4mS`MMS=AQJ8%e0Y(MX#U^f zCqp)3hCCM_xo4TozPS4E#^ydkSYEMTy}!OBjOaO$B(3amyRsXiP*fGMu-}No1|m3I z6M&fvtO)PsC{D=)gT=sU(~g%b7&39|e%{>4jwN#G(Ow&B$bi7CB;>;9)u(Jdqn(CT zwxetW>S$>qCRG{lU0(X%e|vegVnq)jX4!6|udd(w_fPKZBs2u;7^{kedv?#=85C0X z8FX(3C$)h@YOq6p{^vPRUlPwwbqEBI+7ttVp$;5J*7R|-7|L>@%SDpJT&Q%|dl~Fi zx&pHK@V|6j4PYk(D#2)h1e9~rWE)pg;`TQF@af&JZ{An6+-5==1*}y1<=YqDxm1sd z3Q(JS{fxnm=;0Af7Tj#suI02#76w9kS! zKD-lvLskF)<}`J3zHSm#N`h#XlK{Dpgci7RXFJD}!Z;@zjaOZ8p^(V<3^;P|iw|+^ zcyxcfCMwqwuxj|nU#z}!@g&=wac$X&YUTc}e*eks&u*sejH#iOyBJti77a$TI11U2 zStNtKKZ%2q^k9cShCMN-Dm^CyLxJ{4E@h0&HGPC;ZyY*gUDWY_P-a z|BD7_kVi*!nzO8yp<*^AnAZ$DbTdY87pfil1yasn1ZlRX)3-PhIx(;P#HRScIeEt`P>*>i{{`C>{q zlYkv)ACEx=pb*SGQ@JK;U6TQp~*-s zi3j&P-J@W{(~Lv05)U#J74~Geck!pMHjBbnW+V`mR;>NqhZ`ThS&bY^ zh7qCEhF~s#^<|R=doFXjo2w@yFWaNRK;qKHH8X)^HQsvE-hGH^1_Pu*8e0A^%RPJ1 zp~KpLK3qEh1*iisGdMV3{RGT{OAfKN?H0fPbo1_HEXJ|#UOuz@%Xh{hqXOhjh;X?S zs9>^g7pL2e6EwuKBYkz@01Tmuob0!wan)ownTWlFYj@gfw;y;IPn`(nd0J`m=J_*k zUl^}duoiTg$;^@*Gf~x^ph%rG@lZn!b||ag4#NslfZ$em$Jw={GKe-r%W@CZZi}6L zWMFNKQxU->Wrq~1a*_JNp~SFGR(;q380=8r0A>g*;!R+O+ALHKf@BV~hV3b?Z%(go z?(BOdi*yC05z~dUYj0l~Ed@lOB65>rh!)W_S@Z(*9yY;_b_EGY%pwGXLN^2T9IG%9 z8se|t>|fv9YpssNnOeVk`^3pH)TXK?pzJVMkf^c)U=A1%+zI)x1MuAsfOA&rg1PPl zZE|jNPNMBLxOLwjOiVcDhV>^YYTT@&I??!QS-VBQ%bxjwT{2!1}q97WqFH6)J`T;Mq^0 zS8v{LJc16NT-sP$st^GrMABfC8A9LbSD7gciW|qp9B>J4 zK}nfr0F7qu6;LH?tm41?>hxP1tE0RZJx>~WePS!c|NQ3GpTF69I0Y+{0Fq1%VDGxW`NFS;`Veg4R3veV8Ij6P?7O4oVjPUw}lTj_?jfJcC9!?Un z1KFZNfGKOfhu&j|I)#d%0Qk=Ae+HQ9*cYX)`C5(YoxMheoA=UQ128Z*%Q0U(yRsA! zfykoyWkgW!)fbA0Jr`9NhC6(uJ3uq_#rc&cgen+Ps;TH03m?1|fAjviQ?*tqQocWJ zr;2xWnm>Mi`^%e?6oEP<5FrSVL<=s)7{~z!I@sYo|I|S$AZg(eloY;D{D?r9iz8}5 zu5u3YnN!~mqp!gZ-C5^iJy70fAxa?41v$vjiXKCX_SyCQ|Mk}!-#*-(vdUl}Do-1$ z)h}K@{lRN-t%6Uyrj{yG&YVqsJ0eI7*WfrbP&(Zo89VM{7@SZDv1w8j%~KnI^;7k@ zQZOmT?P+`S{zOCw@B})d1g{5eG~%s8tsdJ{OrMl z39PE&`)_Ql{_6E{%wk69vHcd1T%-yFYxZPkYGA_+KGIhhi?k&@Z;vRrd#r+Jlh#FJ zz@Fo)JL&q}htmvNbY~<-)ajM#?bnvq#t1?{5ua8tYo|F>Hq?-V9p0pv!ATMzrX;Ju$Y+=f=!H&VC($e@*RC#>A(#G^Kwm%%al!YNZcLE`9ul9y4z(HU(I{nw7~6~%XoxRv zIi&@3}hjJ zidAhE+g5TnX3M?=oEzi6e0b*li>JrfA4f;Pg!Z7ZFK<5j!zWvtJ5YuF1dsE^z+i(N z@=ADqCrVR9T@v5PEwZ~SCg*_#wH^n1aPxgZS!}Su4xi)imRINwkW&XnxU+|U{p{fv zceWoU%V7-I7+A^eFJC+NfBER_`Z8)lZ9}ABgpeJA(aa1$CbB`M1vPjK2%zKCMJWid zz73KbF$Or0^T;qZytaO-mb}dh_0_u%rUCnc3}&WveDhqV)b#&Vnq$;oj*0SfT$fT7WEL?jeF5`6B*t)jf&~)hM}7 zQ*w~AHGR0XX-vcxZ(pc>`-}7E)~Xt9fYajC?cqd!{QT~Jezm&~w1$@OAp#8cD&+n% z@Z_xhfAB-NpmW_NH;*l@@#zJ{V1pgH?7!>;JJ1LdVITPGmHj_{e*3}1_Y%Mm=rK>v zEZMK$Kl}EDP$i5FmYX^TNjYZ$Gz!crT#fS^a8L?nup{k04|4U)s1UMXrN5j|n?+a1 zxU?R}K+VuN9wooNncIL2Fz`vId{7Y;j_EDc}$Bn znqRzr@~sVoMmK}3)U7aal>O+}Pl$+DAF;Xu@o1`T#N3xF1(sd43&U%$P% z*8(~t$pj#`s?Q{IxM3yc-w|xDWwpj#ULR&fC3~|n8{%u==%>4prfBZ2+HOJ z&CLWNJJE`<3f{tq%5us{tW)4$uj$6t{)1*(Rai#zyI-6?6Hs?T!eYO91uz)}8s-wi zLjmyg8cd{<08Axi4-N6jwdvJ+yAA7LSh2}}{q=g?Xt@HMDd$)PDGOc%?PI8vAHS}1 z8jQh?@C`0co!&th$TG#Hb&^Pu=bVFuU?vxsJ7@%hoC!$46!_cKy?ZY%RhqQbziWI5Q2x=!@S9?GPk84q%4 zUz&;N+AFhu)T~Y>rEdNT0cJ9DfXG&0l|unA*pEA(3p=&!xTzND+Gb;`*}84v@xvmy zZJ#9c=awuiw^^79fBx#>=5FH*#h;t!QNYHjm3LnsjY3CzMdbhnTF#DO4wqaYX{EEH zUI)8d2kpqSs9}llSe#Nhy8?h93pIj;>y+eJgw~K0e*0RMAx{hU5w2<$hhdLK@p61+Kq)agH;h(o3;_9u<2fGd8$gOIV zh)jR<#)Y%5uP>jdF&2UVA$p84&eUB^fX3WVye|Y}%7-g*-vW{Ji$^^i0$XI|5R{|h$^_771ps3NpagmjY82|W4!a4gF(O8jY$;_%FaUxfBLN5= zSg3=B0${M0PyoP9g1Q1=-mmMj|I!{YkwOqeu|m@}hG15C5=k>;2O}n}ShEGL-Og8T z-rrAFjg}nBX^PaWj>7BbPG3G3mn(RKsu22ju9e+9wwp){=XtOfJkN}_lkoe`w(sq< z!B}DP!E38;Zk(t(#?Rt2y{HSL6R92wfM@0n+KBy%PaNk zm(IL)K8}DYfr4`=00w)}0s!4+c-N)x7!M&b39K-2G{VI3?XAg;JDaf4euH{xN#5jk zcm3q}?Mv$$tEe(8sWOtt%7TgEtq) z5QR~M@+|wuL7s@A02u6r{ViSeh->%p^|$vPJ$iI_006@T z;JbbtxN;0Jw;fZ9!#!H-xccqh&ZEicGbhe(#7KmkmqJLbBXB4H27B=WU`F-ONur{x zfgxCvot3#^l97P>5B1HpJNv0>5wbbVi4@gdI=S}l#gnH-U;`t9p~(qG#sHVOOkEG4 z|E7!ItrNUPU&nTs*1-mQk$?oj2?j5Svc3QSThB386pWC!`);4!+`0W=575Sm{%D3GPa@vh8!iZQbtO-4Y7_P zP8%fQ)30`JJ>1Q)PC$l2WKcRevNtcRpF0a?FkuuBiQIZHEv{${1;Ai0!^{Ol>^g4T zo;O4eAFaVRpT2l-`qI;#<_a6g0~0( zAX?ASLdac5U%e-oZ2=toLA;McVbB^F?8hZpG$#tetlOc43RSw`4o2-d|G$6v_EBrf zetH!CuOD4p4oCu^DlmKbM4FQwGINfT9twcLUOZz$H<7 zkbwkjPjL4^bM^LiV%<0e24?tld2HuSF28+oxh5iELJ$mqBu{2sXub@j3xmC+0w9AC zZI|N%8MUF8pketn~g1O$sn zQy_*yh|LW4BeH)`R;HqE5;cRDm1SNDtT74r@Bel^g%xBi zsrkSC`usAH1yWUD+hznq5yb=q7l#61upfUod*QjV20Y)zox{}@K9+^e&0^=2Cyv_> z(zo}vAMT|Vl@Ox^q-jKR;q2P$mzLMYs2t`qC0BJs3q2-Nv!3iw01WmrPIMq)*l%)( zp$f%hyYrLBAN^a+~39**Y4imYcyJRRypNS2q86Rmg~1Kubx>0 z95oS&rrp9BU7(d#*QK2o0F=T|01WoxEh%A94WQ?u?gdz)2o*^#wV5# z96>V~KrJe?9?&f7u-Z6m9fHALxg4u7rWejyv1lnm?$M$W7%+t<760cSHz!y&u}-`H z?Vrxp8NuYa4PXc{Il2O1m;em+@{XrNiFukv)z}QJqXlw6OEkh4S9iDf^3Hyf5NMSF z<{VvPW1MXh-QC!>ZQE9Z##Ym~v7L?C*hZ5yPGdA~8rwD++j#eRf53jYXXlzTI%n)} z|8>+tsHpaIe@)M)vKwr0wwP_rGLqlE{k_=y)i%j9@E=CwseDz4m^ukgOZ9*=Tn-^H zw3Lwch{&W4L17k0+RU2^W+e;~)aSA{twT3rR--#?tQ(kgXt6R?COD-bmKbk#@wGcb z{=tlJ@2?DzJhjcCh)e1Hp|%xeArxqNx2DIeB`#@CO3~DY z=q}`*HJt!0&0b*NNhvU-g?7J#Q68d=EixJa>XGc&zySb#|9-qjykHpsxvwFYqC8K& zrWbMyt%YuJ{~R1}hY>F`=M!gz^X=_NDDLfGVPaDjNe1Z}lQQ48^4-|3L{a61R2wg08ysUJ)7P44-O6{QoaxnAD)UEz?hVK zJNuvAedp&j&64Y#Iw%n=jV*PLIgrL2Gwr;7h2);{_hLf7Lk+Y)@7pl;aXWDm-3Pxt z1a9UmF>XBM$rnX>t=cYJT6A|Unz@-Pe(U(XMlutR+#aOhUCV$ zS;-iJRY{F6tvw-c86a@{L+}zABkS1rQ3neGe0j@4w_{fiAa@7_Fmq|L^$nfx{7iVw zo~t-mf;6%C^P=qiL0ALcL@527bQUetF=Mb(1k|z!e#DLNQalp?QB*x_G#?RL@yjqJ zqUBP{qu9H@YoFHipwBJjnK%qLP^fXr^l~c0{Gza;Yr=faX3v>~L~B1z_7Z5F*hFqm z&)#63IO(<3nHEn{;U#+_nHCI|A%h?P%1a>HRR2b;>aZo?NNO|*!>PxW^sb2|}c`}0L8ZX_Y)l_^_J?fr@bbJocZP*}9Jflf+86#`siRrJD$DnNKvqK8v;URBl> z11k112Z=yYXMPe)@L!x6Wda=Vh(Dm_qrpC7>0DZB^V@DGc^bT%I*90!Z;OBPn%nvN zc5v`kT^(hLLjwFsesg~tbwz~tIZs~#MibW3`%@yW)R3_o`2Ut*0Zg3%Qow%#^jXw0 zGj#970s;eVE6qrsia+io^79Mm}x)=SV|OV|icUp@m0fpcSQ& zgx3ajHa7xWg)Ev}hX|on3L;lQ0Q(rB5 z{)3*G>j)6t*W-gv?DVSQvOlK*1BqE*nZ1JTYQ{OS0r9mUtCc@MDB{GQP$?uM9MWv# z%*^!6)=N8Nr!?t6t~s%T(Ox7nDoM_SgnpT_OnAH$41A>_RK9}0noD^**#_~Xr%GiS zaFQyB6n_Bw!}~X7Y&G#JtW<)G6!2rq*4_XB%6+j*RUXt&tV>FqDH01d>vy}1y661v zZm}jU@&|NsjAdv>cqLdh*fL_bk)!{^CFTU(I0-6e5r%`6{KNG8!O!qNCW8yyPl0#d!d&|t~r>n1C%)|L5$Z!Ij<4(xmiT8`Zb!B za~)nM5Q$kl3t^TyI54sZ0~z4t$tBdY&d}-$kE1}S9^Qcm?4WK0LYQv5=Ns!09m&|( z$Izn!FgGjLVY5t*Ib``&YDlRGtAEXiRc=hh>T_65MO19wThtd0f)#QhKJan&o>FN_ z`f}d=y8E0I5+*96odGGm00d6an%`A)?Dz{qI*+!gNBnG#TRXm=JlsRnD>X6=Le1%Jtn zGxD(%DBQh}yS?~!al&UM!Kwj=CGkx6xvxp|uyjDa#T>3`;rG3iWUQEv+Q1WX@vob2 zj@^)qECJ{gvQ>b$g>In<16e7GyWRG-n>`o8zrV%Leo7{5OR)6(Bcwu2N#t?w^fs|~ zAM(~Ijh32Rq@n!X8jmT_^I=puC0?cShH`IgzSgia?T7W9@o z7=R^G@Bc#rJKboorg#xFbit}_m^0{gwT!Q&%$6%1>iwjawMMo8N(*<`K=ejMWJ2>r zBlB&JbzoeEjk|N9yr?~){xd;>i>2v^1CD-wIv*X{Dti-+^BlJ^!3=b7kuP3!uyA`3 z7yLT!lv5boiyBVFU9wO}>!sz9mqxq%Y$uC$Hl+=y6B%gTi_B=R?wW;MBVvZxkY9dP zxp*8Ha5zRpciirc-RbNZzbir%=MOAb*xwF>#7=)0HlbXB0AWnK<#@(3V|%D8bfv1V}+ga*am+s)Rskk>OQ8O)oSQ zTuT2WI)+1?Ost-rb?D8iR{SSrPG_vF&EP;|WN(x>ir9!hw9R#r9a)E2u!bI_SF!-;?F zgCQbIy(hby0jP16ft_7!hYHRbEf|w_Uc=q*)*igCM+R8rH~pyKlt_f981y6+V2c zIZPvB<^faM=b2g*kK3xlA+arfOX_DNWE+}n+5t5!BBkJtwOn&}SxSA?L75ooJwgKn z$ePguTK*=4;N5{CG>DedNz#^0F zvT%Svu9*{;dsVap>p>9^U9F6ouq^!JH*Z^e+gYKk?d10zvG<)@O!jDb0UX%lYxhr> zyPCY?q34iIS5;g*=Dr)6Z*)-rILs;jb5Z$42Yb;KM?IFNOnX_Q`CG&3$von2Z|8rf z2<$**A;i*CXqG?7)RVZ#;d7;ny0xa(IDQ>Ynf#`4f&99M1xVYBk1?V~OslWZ-EZb#M|xqY zSzk8SA`9_4>0k6Fl6uW+i1h>*8r2|=tAyJX}3=Ro}!4(egE&j%`Rekr^?eF%! z+rnBaQ@a#9xZv}NSed8m&h(lrf8%MA&6D)B|HQVUr^41k83d0+F-k&OseA25dbHqe zqv-Y(z{0+dns^&o=D26(vmjsq_@rlc8I#(J+NO zip(a-_~nWE<1jw!kiLQBx0y{!0UVFcMU_L{-lf^!$?uJ0&4}R%{kKu!7dF;PHZnBp`nr^T)-gz z)=nlC4>3d3_Cn{`nNfnQbQ_2TA80<#Xg_}9^d_Q-7if!HU`$j!xhZ4mQROn!COZUC zGGSXR&0pV?)zb0)I8}ekewbheHUT_CmAK%@nS$?2#1e&UUcP|Xm$tu`SqLA&M)c1W zYRk}|Y17D|WK1#B8&tgu`VNcc^*zIte*j^HT$e?QmNc5^goBJw!9 z81G>BC4UI0Fs9Nb(;(9-W~FYij(I7pHET!7E}TlWk>az)F)_XB!bO$h8Vx+5(<$a_ zn)x^HT;~u?JxoF|W}dYCtv#%5hs~SSY_ygKjgXaUmyAR13S^sl z-W^Ek$CigOiov{9#Ev$4svyxuZa#&8d+Xv0ReEoo9oe@G5_h}EC}Xf9|IXm6qQ^_R zx!gDIyIN$TFXrxyleV|fmto2QyFatb(t`%7>zDl^vh;cP7cL@DL|ftB%5EdpRe_rT zq9cRb)!ER{#-&`918fw`@*cJPI9<6W!qZt?WWTPTg2NbwnE};%if_jSD#u;JMypBH zI@b03n2gwd`+Ua0jkVMHoT`1sB@8ilBNnU)AP|Em?_Z5kiuN0j!H0GOb{JEm?0_ z({M{a^B5R$HcocpKfS2m_*1b|2N#En3>0QGcxS4Lrv#}j$&JY~y?`)PC zLq^3QO=bL{vFqi%JNBgUv)_)+@v7U*#GTFZV?PbKoj~sG&zA)(=XSxuKNGH5m?(N< zu`E-9)<2^d3wif)@H0%$5lOgYrtPN39#G{D>2=~`^yzQnXi*W1CMUsA6?H+gQ>y5(`4Ci>2qgzs8ou`%V2GoKjsE>h^Y*`HlE6z( zrxq~*BvmruSn#k6KLdGLGvQ(fi)dT(%XkQ{fyG7+;nT-^WdoJmtzKynYUiaiuprLa z{}L54$R{*ih`q3=T#iBeCi_!I#eH!BE(l!y)fH~>LZrQ+i7)Y21r2%y{eedb>Y)mY zi6|c_+#dkq0emRJwc*_GZ){@A<-|QLZp)VKa^h`ps0_+U2n^VC2%O{gr=HPRP(=+s z%sO}IhiOBmdQfd)u`7g*T%2^GSn{%UE6~W zzWD~JBb(yyH6&Gk8j%Z=%jG-d7<-?st(4=$A{X|5ST;Zd5GDDC4%9^ZUP|*MV39{= zAS^QVOSo$;G1UT564?raun^$%?>WTUsgq$<YFD9#LSS_N;g>|#b3(`AhW$&2+`4YSkES{@e*omxU3x1^uQb)#3DY|EM z%xQCSuqc;x(v*fis$>0|-g<6Yh%!EHmjNLD2Qp2(n16Xo&;xFD;#y1*pQ@z^$|z`B ze*UAbEj-G2sR|(OZICS2;N|8@(ktRnq)?Uo#4sgU$IGx_hiND-j_X0!+S2Cv@_sLx zey^|4h^|1-vx`Xgcl%6{PWxNcTqNlCEaMLfs&o(l{`5;C0NhqpW_@g9)xj|b*;p94 zH?D2Qq=$~Om`@!7>P4rl4P+p1iuRb)I-Kh%TjzKLJj9GzrLoh_rTObzWUa&9Z&>wt z)H(nGaPncsmGX=@Nn9|m?+tKh2BJ%ji)nwYzP^{4goHU|2h@o$%|8+@<5c4F7DrQ6`Jj|WP@hfX3Vj@-p7VxtmMkl zI6%R8lwxSG-YNoN=#ubyjG8bZB>SKOjpTpy2A`z=Ap!&h*R64vwQq^2O4L;u(3V>4 z1nVqd*Hn2Iman)gj*Gq#ite=(X6iJ*nGD*Um> z0Ay#dYGB1k?~DJq-b=R%nQj05etB@5|9VhOGbOO|^HCt{{HzViWEa|$o1a}1M*tmj zji5PB&g${XL}Vj;AYoQwPV8-BJ>HQVz8FQ|0HKG;A;n(Jx|MXgyEOaDJDz2~+f6*T z5xR}czAN)Q0m^V93f;HYovZt+;Q>KcCf6Y9)QlFAnX}@3x(CP2jsXjC^aSiJe`MAB z6nR(u-MM#tI)Lj9E%0)M+XBME;yZI>am!*afy0dDB+AccK_a)?GiYvlSmd}(2M`GV z-I9crSQSh%CE+D75mt!L&&_kZwg{%05yar6cSQ3I@OwCr^YmJy;?MY^zBQM?$Q(H7 z0!`Ezs+ex$@L7~0-_=rJ4HF#xAO-|S9TfK?CL*)CVZq<#^~<3-_`j0Z_=LuiyFu4yTYr5+10c^>yFmo4wwX#q#;lX99r95w z+(GQ`_8^NCEpNlKd zSgk6{lE=!SlNgRGriX=EZo-LW2PCdC1io7mL$f4PcB5F^wx5oa4;F#_^OABt!`GE|kiTex%*a(-F{ixY+a z)^%GwWmv+ad%VdP5mYv0!TZAhVkP$Y*bn8VwO=2}PlZiLTIKiTu-PM|4A9*rH6K+` zuAzkLH}PwJ$}USMVg}84It*;SPuOB?*}xtpqp8pFFCYugtCTH6hAt23HKKtrpKEY=h^Q!s~)Xc zXiD=!lrJBzLRQJvdS|9Aq%G>_=L2%Fz;+;XlE(^YFlmaC$}|RSj2QvU#0_+T8$ewV z5TJ|&_P%Aa+2w_{3#FU9y!yTaPAT@cev!wIf8f-tG#eRF{Iwkugnj z$#|4062>k-pvH}G{`1=k_YakP0!7#7hJQYMhS~etRW3~lpVn*q!pHZQZDl4UO|?J8 z5^YU>xV$a@qg7y9eLo1#7Z6qHbCQu&$Oi*k-Cs-_XTpLsN-elhW{QsUx}=9>Z=3sz4_8 z^0Xh_EZqj-U=IK}+!#~#G(0+bc8}H?-~ER(#J+9O%CuhCeS|E z(3w>c)jGADob#hM$?10OGPC)qgSL9PAnLg0--8%bW*B{STz?O3o9#0q`!AAyWY%pO zMs-@+73>ZRi1x}A)9eo%zQg=YZJ#r|Hh$ky={Y`B)aK**UE15`ec)Z~ekC3>25lY$ zL`6ohf(m%Dd)vFc-Z5kPLy4xC1uSiZe?Hn6|4eF%gF&22K`+N&-(kne&iE~Z38}3M zHk<@*#?xD3{%X}#Xs7n*iI4lYr-B@=Jj>yYx*a0JXGqb=N-|4e?nHv8ZE24P;KRy_t#ffBL4(qCyHch`{ z7Sg~Ia6LQ8*#}PeqToh|A8FUs<^TKj@xA(`rH+%Gg8vyX>75b-Z3ChfFmzk+#ifOn zjjnOdD(jk}+&(PgdBG{3 z8rJ=FQofgdXq&*;QIr9t?LrVRLONCf8xgh_9HvCQL%Ds0B<%ORW8T^66dEAmxwnSV ztKrCR_y_G`qurH?lm#e9FN)#PN_2=5)Xw~!2XJ;wt~LA;op2fl{i7D1m5^}b@Qp;L z<&FvA>D304$4hoG-+1TLt|QXphQ0=rz=i)8!4y3+m?4GcjgStYV(tp^M+8KWmnpIT z;zNa6aEZZ1p`H37&B|+wf`St03!_FOo=zUb&50amFL`=(3vD7S;P2znrZRMDK0ShfabXQcrFLWr*nxSSow-x9(qtUs8iolkH~;`7T4pPq(_|EB};}?i$pqjl&_PN z83m=KlSHfdtc?Z6&JiE8oQV3oopLvJE^7Uy_j-YV7gqx;C|xK-=7UZ_?ae!_q*n!F zLr|{|x^y@=od-B&uN0xlEK(C$?6I~wT3bKrKXk-I zQJ_j)jK;uH)eQ7qY6G2^2>< zAUInp(qUewX3t>`ymwCWBGcHo&dxd!)Wi-SfdL2QGYnc}Xd4brbz)*>}(#=>%USdx8@?7Rnf9J{mSgs~*vKw9idR5}kdVTqbulktQ zmMuJFH@85dP8A8y5Ac4vd@6_8=ytyC?5Ny8!ZU<-^L8(mD9ok?PLQ8kE)#9^ts`;e zUzEt8E7d9j5PeG_Od6#J#0wR2sWDVMWL(?LK_gSE@{LS<;<9gkPj)v0$>~IB3KF<5 zUMjMGdLsP}3tsl#ug1ULVqmC-b3VgcpsVyDPRpbAjLo%6prDxehQY2g@7bLyXbP7~UaR`_DnrtUq&j02-jcIDxVH_89+yV_0!VXzXj!ea|of zfC3G6G-j!u-742#2$N1z<#yEH;0hpkjAHU|PRS4f%{A99BHp*ol%6lkg}IyvL$LHE z`RY+X)YtF%)viHH`m_PQCm}@rcARl%Vfy-D`?}vZ#=6*c%36a`z~-h+>^wt^3u`3{ z*7_4JKi}K5A9cZYHB5?7KbtLN4#dUB2a4r)eI*lne-(~ky)`oa$o?#*kC-Q+p-nk% z8I2oG?Nh3?;oV{Ac;Rz)vZCd&KE~w4Ig_g%YL*L3``4p~c?3a@UeaiB4Ix7%2ysoB`&*f~H9GMpXynAkHAX~; zc^Y!q8C~nG?^C?Nl4~A(NI!u6_ z)U18CQXtVwO<7#vgB6;Fr?l0Y@$1Rw3$`%p1Ww&?`tscI??=XWxm7d(N3@P)0X48H zN}X-H$N}JM>Gz73S|c7*dgnG60?axLW8=mf*&#&-X31ZM{sJJ?SU`- z9oq#V-`o3>B21~nDKPYz+jDN%k&R-L6xo?(q^MEEKMhM-KR8PDFB;|xeVs^Hi&fz# zE|^Xz>kty6(Ix}<4xXHKC$UhsLUVd{sh) zF_eAX&KDMV$U~IledX)+t^BMjy(gTJ*#QF8K+e2q{)Cw8%J!ER_t-_cGb|uO0Ltm1 zNhiyQV?HNfAt5vwl0f)e9SuAUY>^6D5xbyrU?y4l=tFOGdOu#h&F4}{%gI4Y2*60O zm*xzR&p<8@_QX=VP6Qp)s8^&^%Cdx3(p;jTII0&gNTzE)`C4S7B7fzpQw86SV;gSh zgA&w_U%J>qNIX+x(hlJi9wdmqbNk+rz4DfNO(;Kf8z<(UUSHe+scHNVlNhURPMi$@H}z4+M1X? zqjC#{{R6;qkp%<`<2Q6jaeuAb<0&Y=Q__NQ#${9I9@$(qoYbo6smdKC9~uj{7n!zJ-`)iM zSts)UtOROAy*$L5qgs+lbvE`$DHpwY9mr{qdkoe)tyJrpAIy;Cz-*~;;u$`v> z^omEcDe3*tLr+joGo_FRnQHM`XKB%g#g$S&reXHY=#}^8tVvH})8li(1f!1b$@GxD zUL`k?AR0anbidtBjEI;PXr|;JGdsT_+nmm)*t#J-ZV*OaSZv$3$+|FOP^u?UbHNtx zB|y9GZWRE5ba-Bm=5M$APs_Bvx^|qVmfT*F6C8D1Za_~*GO<>y_%vN~3HAu#^qOsE z_ACAGQdhT~$4esBbZcZgcvFzpNH`zk6!xaloB%(Q|MM5|(}Wm7baW_}5!a~h2N9q= z!!M_VK^&@NB62L&M=+5hyHwuTF)~e%!2{5ozTaYBoE5PMxZ~y6u7VUIRuncFNVsDOurARw>y~BNXBiE}D6lNV9 zdJ0^~Oa=Fm(9AK=prtRac4h*84?u(6>h3kSRbt=k$vM^R@H59*kpnM_>AneJw@ocW zEVePCp~?x2{#p1j_jxLVyJ#5PHGe|G5yP0~UB$H^V+4mQPKmQ`1PWKO1LGZfy}NjM zxCHnl!e$zzn+8KNm`pU0=7`SqHgB(ozrAP0$5(QvvQKLGA;oA9^b<6o6yRK*Tx}^% zcd)cC|4LZUrQR=luf})xuOuSH zouqp?x%Xs{X?|37yKv*t>We=?^7|Fw5Ea?j7*xDv{k|U9GBT7-fytX&CtS$NQVfNO+{a`9*XNt1jw3i?GxnXn*`kN?UO5V%8emP z{*)!pqjZ_{JM?OgNbl`>*HR3eAkV0i}^pSBOhu&k|h0%XU0hO+Ps11>_5AGl|vm91T34YM;z;$ ztd}Sc7!@3~@lHG#bQ){VeXNmFA$-EDO1Nt7`$rgWaxgfl!qz;@;#IeoWX%nw%=uZZ zAJP;J5#l@F_)`HFlp@~GDM#0 zvJ?b2munA2H>-T`om>=OuL7T+ZWP;DK$ra&Vy%IZD)7*oCCeMDnn23xF+hU+@VH&% z2;o>e-6bmX!Tycu0O1>9cVlaq~S zoX+5=*M|pLRL_WP&jj2ri0!xvn6YvSpL=nh$QDBDuv2=JRx{qV$wZbf<^M=peH_gC z!sP|WaFWMHbJuXno?1|wn)$atZ`m8MRnD?!wY?yQIQc|bv9XbL^NSgkjZG|DWKgrq zQ2|Q|`PKvtgaC85L!Io*F?ERuj%Lf#LsCTich!n~}RGG*Ep3zYI!Cpp~0EHj>IUTIe)-Ly# z`rp#CJ)C|E`eP8+69UtZODi|X?9%t-DSb2= z=w#(Gc6^P-8+`F3K4rQ!b;6`bXLSEr=P9lTf@+Q%Wl@1t2*rnNDs1T5M0auG>LcgN zKdpbD#S{?1#rU?wofF^G$?>J7RMWgVUflqqf2bld8UHruobU>6OL56kD3Up195B=B8smnV>chHecwm}ndTBLFW+r(vA;N70tx6vife>?dfTOlul z>=B(N2mraAbKyri>QGYw258if2y2ALhJ4ZI0R|UCjHmmGm)n;JByuGK9PIUKdFW~4 z1x;E=w}cv)z|VL*=iPO+LF_ogzylC}(j@Z8nDr1QP6J333(DWcb4upL1fx|tE6|{q zF^6wN?TPnT#?Y}{T|PYxKY0vIObJQeKX;&H4oOInz1AI`?ar^S%JHFMSjqO~&=31E zhUQ>q^f58-Mt0!1zsD}-QjX>!VW)@mub>r5&ieHRCF4%|YdvDG6|qg41J zQ7wUzOBrvyWFiKug+EL3KRmlVJ@+|n8o$CFnbk-ZIjkwi|GtCXYTDjk^m~V>zUYvg z@hqModgq!vHDFWw;FNbfdlP+he$4QYwPwz)9o%;&dx<50XI+KG+WZ1syxUa_T4Gtb zdV)uRe9@n7=RCLT&^Eh#PbN6Dk)~pNB0*d!65jWwiR_aqp{Bhw_%H~@IPEN8?Xu`! zOo$nuE`V)5x6WN2hKb18O!5CUicuWvRs;YHX}J^7l9@}gd5D3}i-(_8n#Ci%y`0Ru zU(g&LBvmaf2IY5M_?jnqI$%62$S#G)aVghhfq3Sb9nOA- z+Pdyq#q8MCn4V0p)Kdc9q@zuR%a<~j_gRyWLks||d9 zUDH`#rAFkZ{6&uCC6@P?+3EAJ)hjHvZ#ZVjBEm5jQ8=cpCV*U7&y zgUxB!SbQBbd0JO7U-h<7Vqu?9;yS2ZimEozHHS|uqGR(|>}5t12)8cq%}l2qMn-{# zs$5c;2t}rK8JfU1l@|xG!u{sIvN}x9@8%zlRZUPLCFg>sN=T-Fu}klC*wkI?@OQi6 zzXv*7_3&A6cXfRBr2CS0dHgAjKR0K2unGI>UhMQjPJeak@>_#m2Mzc@q_al&FZJ`^ z{g{EnGRPAXG4v&?y4dP|TPNCV55h_EzD4Ha5O;xhJ{^o^EIrc>&7u<+_e=r&tZDv| zBbqWRLm!&szr5@bgKP&TdppGY%*Lntr&06+4(&YfqJJJSGL$QZHQ3Cl!Fiieu_4NS zz{a{Y4~aNu#565xKI?XR_AL3(>ZEtwjMsHWflgY+TEQ6~`Kch@(~)$udzP%W;)}d9 z5`8gf`D9EJ*=3TLVD?-^ztJ?*s?{(lRe)MjTzw&~r)Iehvi7;mK%QkzP(}>+iqSad zq?ImV6!la~EvJJC@RvF?aZusdlTR@hpQd1kOa3yIC4M^~cp3PfY<2i$0371?zWGt? z3#W&ul9l5oP0K--Y9b?t>D>!iAJsAiyquR zL{F73y%dfjLVv6|bQ20rIdoN$uq!UUC!3ox=K0i^?$JB5P~P%){)yrG&EMhY-7MO< zMy>X1lha)P%T6{r^^IMg12MA$S?&2If*g|@P+QauA^}c!5FUP#1=!;N($mI3M@m~x zEs!ou2fWT&ndFrMJAZv#T7LX<91ul8^2qCrH00&y)NJFkz^ypsO9I5Q8WFq4D1yi_ z^p?ktoTQ~+@2K9MHicCRkvI(6U*E_AUM!{aF;Lp50pz_~IfPM}mq0|| zmmilUvJ)+1(>SH;n9M4yrUWzp+;!-4LENM(+8**)`9*SE)sKyE{E2u{Z_&bp)8a#O zEOqYi>Qd;3QY4`efCmV`hKYn4|5qO*IXJL#DfTwKJL;8?sPl9h_}Uqtj)0g1orw@$ z4rSVa3f_=}6R+j0?b<;aJ3Twl%wOjyR}%#Jy0rLqf7*>tabC0;oHjS_(CElta{Nk0 z-^PK>M{47zFH(h6uX}XIDq!QJ53gd!xwaLKhv(&yL@FY-Z0-Q-Nr2zG#X1=+J5nx< z5_80dhUe)>vd({sigyGREYVhq_4j#Shy>Bj+S<$W#C5r+K(|6^z6gVoxi>tL?eis_ z*qd3gXtBx4C0keE;Ra9;5QnZ>>QfX}RBZC6!*;9I^D}TpS2QzH&;o_!&oX|WM>6^| z&*jJK;2@hHhxmcjW|!w~#HPSGrgmsO_8LPFc8F@|ST0YZsUm=ou!f12DPFzHv-eA= z9%in5-#$3m^*8@=UXF2&qf2u@_O@yyhU^3LzBM~p@C@hHLGRA__kN+lw+cDOx>#}f zOJqVb=+G4NFW4N3LWV90iPK5b-x_sz;@J-nsMhldvq6+NrH^qYzW^#6{`&)y{nN|( z-FUGR)EXP>&)VUA3|VloBw%vD7`s`R8^)n#FKeK@56_9h6;wr z>0+-Na%7t_*a>rbnGsB*Xq+LR5x}nXw*h4c06msaZbk$|z&edoWP04kWwI5UI{yti zzu!f`2b!3GFu+b6EV;Ecj4%a&J)@1vH8JojE-c*c4E=s1Zi%h~6%qTo6mi zQ~_4*!CkU*_6RCtgS4%Tj2+eqB9JqKzC?c*a_${1`yCap^XqSPIeXmUtKy%4MfL;SzrE^ffX#)a8R!Gy* z!M7vJ%Cm(tBqh`*QAtS7EdW3yeuqd<8nt*7l=gHP1y1z8GPY-db4^A|8I-asIm0CTV z8Y-2{(7pOdI0R=jNQ(tz%two1TGa}NfCLg|C-Nlk5XQ~N@i0Ljr}?_!^~3x08O#(C z;T+sZ!?cXKbYfBT&!djdfarl7?(za?)bXt(P~21^M*{h~W57(V;D&&+8N0 zvbOC~i5ExKRoTMWkAK8MmETNxios3g*l^m=0hbQzFXnVNItLUoO-Zo;L5v81Dy31> z#PC1Wr);7^{yxnZ*KjSUkp6RV2?Z6}xilVCO6Qm-@NW^#vobXRe{{7Lqao{Eq`Trp zry*cJIcpmUk0*}HL6E1ddH;>&B|GQ9rbr~Q3iBZnmP*k77G zoIN$8NeVzo!m)^A8P6Lh?-OhHlZ-()_)t1*$v^;eFCrO&OB*~Fm0q${dLlK7205WBMJIZPx1MUr)XODG?5T3F%mCKF#`j66+x=lzNSNIzMkcgO zI=N@;r!x+d#(?AT>~*OqAQhyENX-NM+40-HVITG5eQ?qQxwHnW8O?!3^sQP;jNDv? zTpZN`-aR}hXOD+AYWBY}*Kn1_;hb4D8}LU-+8=W3pu}e}IVHi!v`!qD9ZQ+v)!aLa zB`hW&WZ=jbViko;%@1I*Bh<^>);j;W(TeEN8rg!1=0G|Sg2Q}z3WJ>STgOAAAer8l zCO(uTxt?+CbTBzf2sZ`wAkU7b3Gepn!}&w-sAoDTMa^>l%YJ*6kN=evdE!!a`yr7b z^_0`pHoWO4PIBD|>BM+4A-V?WEyVJ7lHXo}HGdyo5X8rK4iJ`vxt70s#4Z+UKMxk; zrk3d^tjc__=i@wfJp9<9Nd7ztLNdzavZ^zS?& z*-S@NKPd)$*v5}*1gHd>UJQIy%NRW@?TEF8Odw5Esp0w8_kq%m%_%1%EP$Z6xoep# zpS$)MZ-^nKpb*~sP5Mc<-y`MC(2re*$J+=`)F>EqYA?opW~-i>Y`wu?NKAkfh788g zZjlUFoo}ZU0nbk{O_Uir&M%XJULV@h7o^0nzvA?qPdgT&3mp2_Rwalau#a~D?Q^;e zf0~J4xV=2NmRX9RaYLD|s`dU$nZLVd;4hh|$TD((0J4HpOnrjIawVoOeGXZhLL&0* zZt*}b=LWwq37P3UReiXbn{)^QT0i`8hjB&*4OYI6WHxf#FII7>_3I}K?4%8af&t9LrOZA&Xt1L{ z{w?AD^5^-{QYXIX+VoNLE#fDHQHm+G@{y3L5T7{3h3Z^sy(6KX7*oG2E#3yLb*=?K zu{@BxulPejiQ{_Wnv-Wg$;QX_nN0?3TmsIb7a_@8z860VU`49bb-L4A-w}9n^!E$D zpftUxPYD)pz>vu_k-yf(ZRI(hb-HIWNT&UuoAL)f=*6k9TX z;{&eu8{AJ{`s+F$-|0NNUVv%hs)(tQiqmzjoX&N1CYyUOoi-r$xDjQSTo9*qa4z!W zbYQ@h3yJ|VDBN-Vtovcug~2T^+S1yF?9t)&xS}8R(&y?sBxZg~0L=M3V#kl4MmHj< z97>wxaNxx_S?{|{0pe&CwW|P01(9d?MIu51VjW9W$wJDp3YRmob@07L*M%_!uurC! zqFYC%&by}Gb*B!b_x?SUMVCzstx%GUi8UH5$=|Gvcf8W+;~?Zd#GoXdxW^+7n%-cE z1dqgX{D=6&^A3bvOOT8&t#ffvITkzQ9K^R@>tNlKobpVnn^+>ThAcmW`Amk}{0XR& zf@ba&_0L5)vgfzL!+ptcTj9>$EYV{BQqfb|y+}878b*khCL2o;?7y;ge)+roor;)wih#cW%`~$xVNBfo!H(CF zczKR;W^F}WM=L=S?(DA`_OSl{YYHr|0L7fGyOc^>6gLVfi)Dmd6^hu)&NW-U-2xHo zuoO|iNB)KpKrvZZqHqOx;j@B}^4V?BGY!BmF4v=JD2bb4oiC>RaWR>HP`6^Y$fteZ zA=c{Y8@5Ht*qMK$<>*i((4O2?TD=O%!2B@*k)n)yOVS z3T+t@xMgSlZ0R~W32Y5F%*NP@b|{^Mj=`Vg9wa4zfA7A^%R<8oJOovCi5aq@>Qoj( z5!cimWBfk;bWUucbw?{V`&ElTF8EJ^21^?R#C@Vv^opI0)$Ex=ni?L4ewn{Dv-bV3 zrK=8z@_G74AKhI?gCHE;h;$3m(p`dd$I(c4HwZ|>Nq2Wi3rKg1NW*)4fA9W#_dL5h zJJ0OIXJ%3~08mGNyk>%Nn_o!97CTKu9(z>4S)BfrZ`Pgn{&w)S>*%n;KyrT)J)A_G z7)Ct^zeF73&C>?hV?_()qAuYahm2vevp=1f`uVqXu6_=OIjE$kKnNt|=S2CcY$^CD zw13K>Mbi2(u-Ura-`jeA!%THz6 z**HsK5OT8W?Pr0=;Zu4bs7G46xGmWx+HrUTm!t9a=H+mP&x6m{;*kHH&~1+gR(RBD zA$qkl;3De-KtpD?1SxXaMDJqXDEsLoDh-t;-Tx}dXZ@vlJ6|Vc{)g3%>J9;8qnlUk z9Ohy}x7wo*Q)1jHd2=l10ZD&!B5Ts^q{_g++87W0AYhaRLKKV`qgD<%8HPw>^a^eI z#w4M)3#?@48dZmz8|;6~QP@)nnU&kcXiUa@(t2<ze*0dWE9!@_jWjJCr8x^GtDmaqYAp#pZPOgf;Hdhsg|+wt<0ABYk~Chj8MwmG?^3 z5>vRCJf9pnMkJJRza~a_{yTMEUK0@!5Ic!W75HRKT8EL}r+QrpNS{wTKBEs>W)EI| zcl}UawDtRm>pLOb!{tjdQp0Io!6q5;ur21d$bUF^P!NBMxKy$&20$|MHZIRz%;zuO zSI#e8*FAqw);q{oAFNpxRczz|fIwY3-UCWTK>mm4D7THcTzPC?@5rvlfi#%&=jLqn z@ocCm&igO2*Y8EMy&vqR{Iw!0VZVlo3pO(cU&L*02)CSZ6WVDh-{BEel;)ocu-2IL zEWY0)d--avA~R5NeSSrt^Ssy)n;ku zEGeMymdbRFodM*{=g{BWe}}YMcQlPsTs5TXArh1M?%`YfNLbE8&hEn2XP9>j6%yrR z`(|$Sr(3a@(j0~phr=3W)2V6w}Bu%T%!ofCVNO zJiQ{JZbC4Si3`&$A1c&53Jo6;`&y*G{DF$L*w>L)k?OWf9O(ahas0KifGbRu$`PTOu!DlhFj??Uz8 zR2U%bx_?<70HoFwA9MSgnLUrXHsa_ZY)n!+A<(hpfZV13g?(X#hE*_ouU4Zn*CwCP zQdw<2I!M{1)mRl2<9`gZ_R59Dx@X|-BeeApd|_Z9$2B~NLPa*2A3-Eha1>9I>YE2Rj9f|@Bz?x> z=+3?%uq@Tb^&x$?HCuQP5p;~72-Q=a4s5Ih_?!#CL70Gp_k&(9W5;aE11G53aP@YR@W3+d#mg_|`-W2LvlmBUNODED(!^MFy>7Fj$4 zg?HB&Y(_0f-Df*fbzgnnI}>RjPL1=`m=}+S23`xv_BZ==0LKQkv~WurKuJMdt6AxL zy296S3~hXflR@?D@_fnD1U}>r>Y;suc30}`&2`{37lB_-XYrXkEPCwif*y?a9Q-R2 zeBWU0f+1NWgCk2$Qd;>L@^7wsD@PRvvv$C0f^F%uP?U})n z{{fYJb2ghyCt1ydZf)`>&Tz^sM-UsHJsk@c+iPK9YMF_4_%8+zGBtVi z_h;v*b6x8B@jGsXwPHp<`9K{`SS9;(zKP$=P1H`Hj1um%A-t*=+*ZHc&gV-zRB_3^ z2K_Z(SymQJi^(v^ki_A0GTqIS_I}`VnFWWuOc;)H!ZsWF*K>to_L)%#q5id%+$c1I zR87XEVnax>j%ipO!is^K6`f|M@9=c;_-D(xgVNc(nK|U^50+_OA*!Snff>_JLyB6U zR+Kk*Il9D>>mE^^$8>SpoXCF-7TqG8c4Ffm3| zC3ArXvEa8abB3#8%M8)PG1^VxHMe*^v7fZ__>>P6cS)=ye2REx2#i zS}g*rE9F+tk!B1IvjftTd~XK5Fces3ya^S-Lh&p`Tg9{mi}sc^xbL4wtBPO!Bt~=p z&bDhZiRRZof-*SJx{g{N^^C@}{jH30tcP|$$i(pI^Zk^^nCuH)!nY%L_s-n<)#1vc z>_P9PO6*OxNA(y#qI(RFN!UvF5H$2-%2D_MK#4PzQAEL%m8mmJZgo)$(#f(sZ1-z( z$)`JJ}L1m0f>+{=2m{f41G z&;2xIEH4UO_1zY91P%&n{%{4G3B%-1)Yz%_uvONNiOyvQmuj{G3DVJTUB`}rmhj_2 zkntlnP>p?@F~*<>h8rGS5Z4YpOX){&m@$nGu_>_`2GKAH8wmobBydnXs>j&akRIfQEjEk9V;6dn-Aa3V_u3XdqLT!V$rBzuX-RvEmsM&D$X3i^zW zRws*o%Z6gJ?4HO=L>T(c%ESRtgF>radZILYN#e6m6|!ZPcjA+P#`!7Z3TKF|ByH8l zv{>4WC#S{@I&Yf~rb%gWfeeVIC&Hi4R$zTHpWiLbCDrkMI7h!gj0F`$d0P&L7 ze(M*+bcs+iBJrL%;W$~yX@ep);8{2ZNJiA}$fM^sqe8h9-l!H;zL$hy7ap)ay#>eR zs&hMyv{I}b=SX5L zh2j={=~hwJfUcR;Wb>pZausYK69$rIN-^6(Mp_Tp>3iQJAmG=p5+6i2YMPqVp$GQZ zp+MS{my!KlR zcbp3sD?C>#d?YbOrh~sJF+GlF$3ePxm_A8CkNk97NQMMx|M-gjk}_OBULSX z9cCRWl$}u^CzLQ@hacaj3>9$mBnzI;2FojI*ld|b8iwF9lT=NsT`@n$%;e^?-NZc2 z!c++ZB7KCKWHyC-J&ardR0Vh}+&#CSNeuGoI|jZKggL{>{uWf)+dG$#LNAM7@jL=ih$SYDr32mRISy+JIer+< zdNsnmre1|(qR1|wnC&j)1+=bx+ZJKPi0EO#n8c?xe*5Y7C&Ew?N#H{Gl5`X&qS^WY zyYX_-IrwWY5qUUkQmu6GdWTrd)v#e1S0*pbcV7RLuNTgn^hygTv+WYE%sJ z{Q91qpeQ7n)7(5;!c=Gid2;n2MU~HlHI1pjL@2RR7oBhd zd`p?CdPVJF%7$bguRLI9&lvTdCPmHQ<2M9ji@_!~TRg;;m~t_S93^@*h6O~l8)|ja zjclU1oZM1$%Q-QFdU}va+`BIR+=ectII(^{1tl%GuyV>BNCZ7tyw{>P?{Vj^YZ9f< zQD4_>I6oGx+%Rq>8p^y^&MI;=m~x&m_i-^%}WmDxao9Z0y$ zBjCoxwTe-+)Q(<$B8o%N7DV72!MIe82^W8*xnLcb&OkvdvcSSw8A&cAUNlwM$~=VsEBc*3?vLaa`#5ES5}Y3tx{^l+3h z=3_IeX-0Zx#W~wDr=9cEaoq5Q%Hw+AWTG!2rT3S{A?)U>mOsH3WJ>}RI+G^;;(9(P zjWewDgHGHyP`*IV*MWMK5{$mCQFpDkqhAAYBveqvft)@GwNQIxpT|d&-_9)!yD}!* z!;p;An#QCW-|ZJ3*bS1W5H)Vze!p>6_eD=X#9Zxok5Np=N>*tmC__Vr zY|BGyc8z)s^Nle5GjkWfCuXbR&5)V*b^@NVXGTy+))i4UePB|Pj9?s=4CoY4vt66A z4Lv3#z>4Kg-WCYLiTu;o{!O)AYjtPO>t&Nb>J5RtK!oaLFBb@hL5w{HAU883pJSWR z#rDjA!|_fmbk4+`f*e~sZ{So#aMzXJV)m~$V%BdJr}}JRGM71$oV=|}iMe9<2W*wN zEoX3-VMzrnun_0eS@LcM#;X_4FtompuCSI>!4d`K`YP5LQ4?C8sGk#GxBNv6Z|pv5 z_YdZzApJoA$ifPYY%UIGBrV zJTEPNAkPyYx7{2#TYw`*Z`*6QJk_SBZ{Xn6oV(M^(VPGZsMlx zTQ|$hyvzT{+tb_vxZUX~=O?o^2&k9u^87Io#)kh}33-n3a8EncE4FKj(WzB=aEe;Y zqKT_$_ZpT7*n}O(ww+vfg2qv``5Ni&NY$UoIdh%Ksd4@!BQ)ZmPlS(YISMREghFqd zw25zieqi8I^MPl6sm31qU36a_?v=2jXxBSF@u*q;k+VH#={IQpKy{;to1H>qcnr14R_~ z`I#qdTAHzYLN96T4kV$NGAw~AP92DRA*Km<2%d+JC`(NdLsMPaL0O0phh37VP;bum8J5G9$^2goj!<>}C3`vR6cl5fS;d>K6yj4u* zDTVW2hj=N}2`OqO#$VcR(PT@e1shl~47~c<@@jOkYsSS~_{)8-lVdFcqW1%#$Ya%S zYh|r-w4aa1a%I?TZH&~({$)nh`!u{>>x%_cnp)TO*~C?X+`5h-(dWJ-+PD{=O`JMs z_CN3){DxI9#?{_(J>y8;?c#Fuuk*V-_}**kD0PM6fpty+viw`A#rkWaJ*8jbOPM_E z{H8Db>ER#Lm=t|06?%VMIx#e7Z!LesjqAJtBU#C9n-xD~eIRrhH<+{2IMb-cc&ICX zY8_Qzn6=|i6X^uw@eTaKhqeE)pfEa3_D*ij35h|SIfJNpxHSD@y8QHYeVaQbw74Tg zVo7+(VXh!ICY>ZFkT-XIS4z7TQ>%?Skg5#aN@txbFdxO+IN5g#=GBaR4X8j-2vy<^ z3L*6l^)9a6J@C}OyXHyhd~VJ*uac6KPft7F!-Hp|(lw0$sGNxMXU0Z+K~8 zUOg3ga35J0_-SNw2L;Rs*k*QN80;>{(GwRGW2!p*Xj}vB5*CceAe)L_f0q~LS=76r z-Elc1^(SR$&b*oSB$%bD`Dfs=oz$a&q1k`7NdqM=+uK#(BGI}Rj*sPT^1y72SU@V? z=C&3$dF-!aqABuLMn)$_3(+%Y!Tq29Kv)q9j;7m+(zF%NVqYhmMHr-tjV_2#P{^Y> zy)h$(6)%tq;KqEny)~V+AocZ^WCi>$;03J~dg^PV9#d-?V*C@6 zY#h5|-dMSDa8IQa>krO*Cnh+y)ctna<*a+-&65QHU*5&?k~QmlluBC~;sA*YUN=pT zx9`7$Epzm^C^1u;!@jmLG2=F^by;~`uf`i?-rNNTdl;_2#^cU^&9j>-E&NL1{`jw- z;r#mDe3x27Wmm;Q+HOZw5B#V(w9Ppz#WRczjHt)^dG~t+^GPI#qfzsGr#T9v69-~e`mE9p`WWf@Cn*tQ zG=JI4{Gq<9f*>`i?$Gg6B+sRS-&=0(P#B2`E2FW4OTYLzo_nIYxXM^WukAYP^JRp- za#$BJ!QkVt09OzM*Hv~h7n&r)4QIg9%xYmiLSsej6VXWgQQIy}KDat(^Y!mv;NdNB zqy?Ees1THh>ZMFuy2Cxb(Ad*DPd*P65$}@|h&JdW9qbHC2a*Zr+D^blqiNJ8YzeCA zudQhgeHZij@teNLZPdT{K&*2e2?dV`*GmqWY=}CKiQOmG^i@z`o)aWJoob|QR??*VS@6}uW}!Req}|m5QKroGDgF_1DEHE<4`R$ zwj!5nbZ=$k05xF+b<2nML0WvCVn=jG%3zif(RBc8XV<=-h?m#l!Op=%=fh@j{4_4^ zMTj+Bh6~3RHEwvj*X#_LCiHm*KiWQ}6e>K#2?-U$ypxt-j_CK1$nY$Ig%2u1uKImh z-YT!(%=%cOSdnw=H95?AQD4MO;d&brclN6`w%FxzVD>7FcsPtlVNuydP1q_8w~-E6 zp6{a?ja)re@WmL%Vwxlo+wd2**0{4+#%p9RKB#u_+tJwBLYPkzMr!ZA^uw1E0w?D% zjbB5WGC%m8bi#j3WR1~+6T;@)oQeTX$#}MPFbxK_2y)ANp%iV&Ohg3(06x{~;Dn~s zugrn5s!mT4b7MyVK=2f+P$o4|D(0kob87@cQhu9ww*AHJfC~!7UH2a*4=vH!XjS-P zoblbn>d&IR`kG}1o&PPuu`j*q>f0eXM$c#%E{J9umX``Ts_k1NNBOhG*=MY+)D_w0 zh(B%fO0_-70ANyapGrWW6qPfNtvEmkRuSX3ZaO*YpS!Dlrcw*kJcav|<-YH|AHLSF z4WFNUiL^8k-WV9W%2o#>VJMP|wP2fjYDGADehiyo_^FVl(_F|kS8e>dL;e<%lNNMh z`<(iV($hwrOLiaMo1&Gx)vmkEwe54in`+#|@!B;kz~K9h1vuDZtZ#kvCWq3!lLe{L zT=9B<#0&_umz!&jE`$hNDYLa+cU$y`a^J+JG7`pqsLRD`whfrt2;NkCkjxAj%Z;a@ z7(6ea42j|bKg#POlTsEUeaDKKV3oE%{_oXcMFFM0T;;`uS-gLRd z^<~`czMtx|u!6wZ2qPkw`3~Hv&_urL8h;?g^C(W^ViL6y_l-rVl0U{1{3WMk^u9k(DI4?!BPb9#Kd++E(?TPS}xINN+f z3!nk*E8&)3wSME<885tzis}gg!MT1?!zeV4O~y@?3v&w-*1{cUQ3@|pHwAAtphDyU z?XQ7UOw4s9c<~7aABDf~jyg@sA0kqF>WgrIqio8?fF&2^h3bTr*K|1;t2>AeRiFa6 zcX8PoyLJBpaNy*+2WyoNLN!rbJlRjEV41F|08ghP9(5M-7@qt$xNvw;SS0zpsr_#N zf^MAA^m(*=F@m$<+@~*%YOtO0_-@HsQEdbWV^{8<}YKv{>=0E zWLkWlKW!Ju1lBFJ7qv>Fb!_ja?QR11%6Wv6?4jjDyJGV4FcDcI#w$|+1YqkKwICn& zB&0F+sTL??#;o$u-9_h+i#YBfk)rHPbU6dU|F-*RG$NGQh;il&cgBF6DK9=%MPAxr z!f{It4dz3JX)V(im{t4VLhV#hCpAj|AT?0c@P!;Ly)j5G3Z(C-3&xeW_Rr9GK_YU9 zjS<3@nbG((2#@1aQkfmj&h;||S>RzQj`1of&%7aKN9j^M^!~%7Zt?5PnCE!g^}-o& zi-gT{=5MOHesZz&4z<+E1HFBpQx5X5yo&(5uVisS_trdAOE}t0$AvI*t0h9mVO97l z`H{Z?q!$XYFh^WwJg1w(T;@<3mYt`#FvJDR=<|!pDiL={Bnqz72h~!`&ImJMjkhI` zZRZRBUp{o}jBZ*Zi`4u~PW^niZmU1S^fN#+a(bN8lJ8kqE#%d0O7{EC*|3V7WoB$T zb)HJxV1oh0oLwlcgzswi$o4G?T&PDYf7l=-k^ zk2V{}D>LV{Sz zOGK0A;{b3C`9p-jY~{YQYTmf!-2am&mYW)Af?07zx}iVvO#0&4B|#woN4Zq#!T0SK zF>k*onZJ33f(t&TcQQHUs7rgl`r{Ft4ZH!Zc}Zm;?$=yQ z!|`5Zw?YO){;w)Ryv!pZpySqb(HU5Q^_&LuaUq_@?XcL^m!F-B^s0=B9?NKP7BMI-G_@Jory-Af5m9q4LcQ!u*BVr&5E3%;I0N|sIax3v4+Bb-&F7CFO=>4~o ze44O~QBAa^o#8B;KNEz$lE&IIPSuu&pfFgx$r*z{yd8##5z^eVblXsYa#0*@V5F-@cJOJimtE$3R}jJ{|tP4IU$a-zMS0b zxe*ft>ahVlcC#^0q{IY!jQpSfURhZgbqYKT5Pw)$>99xo_H|rdx4Hl}HvJG}yKasM zSTv5)+55A(P(A3w;d9$Xy2fc-t}hM`LG&~&LnNb4|J*1_eg*tzFY>gP#>Z3gbf@of zVa38qsqI3q{$AYeT1`#n8kqu+L49q~Kj2aks5<317OKy{MbwF=Y{y*)q?*^I|IH|h z^}@t5RgSZ1obbhXKG^IsvtGhhW@hlhGh9Dls5Lz`$+K3<_t`Ix}w5>T@8GZS0Sx;NoG)ze-ToBjL zO0jKzDhjq@D*7$&sHx6zz3^e2U;`j_o)O43v5&v&?(~7gI9E9{tFMR;vE3f|_4!4F zZCmA^aAcA^6_zpYf8h8KvuRy|fR=FEk?*L?o;Cl+92p4l*~_qcA|X__;a!*Ae-`Ax z{{If{gtncdDvblKaMFSSAbSVOL0CDcXr2BoTARcP{SfYi*OV3*&8fb?OP7>h+*UXs zU@l8b(jdvlx9tE{p}4umL_3f%V0YB5UP@4K!_|L=u_4FPJiO%%CKtKm4P&f!@)vU* zVgI{bV@fHgk(kK&y)?u_ZvF1U!Sxn=Tg%X$aIU^TYPc=B@ZVIq(k~PUH;D*y=gs-q z+_r-2!D_jY4Zf39Z*fxCH1Gmu#*^3cY+sLhmtmvAN&#ZZ9+sJr?Cxl5_wgb9>}YYk z#nI)7*4sKc`|oq$-!<&>w-8gZq+rNskpn=GL`?>ia+2FOvKxBTzfaz>d`Y<) zSoQ*^|5+|ye98)^UX}U)Du{~8W>6}Ifu2=?AFYM|7u;*U*N%QtN@B@dX7b2HYgg z##HA8$A<-$Z|AGR&p9Y3NgP4D(KM{CuAik*ak*ICK=_e0j>0qlvHjZp2Ncw6F;&Mw sB^Ll<0zTV~$l8W^Oz5UccyPoE^|YdhUaEU78UQxrrB$S=C5(gq4;}%z?EnA( literal 0 HcmV?d00001 diff --git a/apps/mobile/assets/splash-icon.png b/apps/mobile/assets/splash-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..03d6f6b6c6727954aec1d8206222769afd178d8d GIT binary patch literal 17547 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 App); +// It also ensures that whether you load the app in Expo Go or in a native build, +// the environment is set up appropriately +registerRootComponent(App); diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 0000000..161fc35 --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,22 @@ +{ + "name": "truetunes-android", + "version": "1.0.0", + "main": "index.ts", + "dependencies": { + "expo": "~57.0.26", + "expo-status-bar": "~57.0.1", + "react": "19.2.3", + "react-native": "0.86.3" + }, + "devDependencies": { + "@types/react": "~19.2.2", + "typescript": "~6.0.3" + }, + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web" + }, + "private": true +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 0000000..b9567f6 --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true + } +} From 95e3286b7332f05da3be858ae1f75af6cf229f02 Mon Sep 17 00:00:00 2001 From: Richard Kelsey Date: Wed, 30 Sep 2026 00:21:19 +0100 Subject: [PATCH 02/24] feat: Android client for True Tunes Sideloaded Android client (Expo SDK 57 / React Native, New Architecture) for the True Tunes Sonos web-player client. Ports the Sonos client, WebSocket session, queue operations and office services from the desktop app's main process into platform-neutral, unit-tested modules, and exposes them through the same window.sonos surface the desktop preload provides so the ported React Query hooks run unchanged. Screens: Home, Search, Queue (drag reorder, multi-select, swipe to remove, DJ section), Now Playing, room picker, album / artist / container / playlist detail, lyrics, leaderboard, profile, Queuedle, identity picker. Local-build workflow; see README.md. Co-Authored-By: Claude Sonnet 5.5 --- apps/mobile/.env.example | 7 + apps/mobile/.gitignore | 8 + apps/mobile/App.tsx | 20 - apps/mobile/LICENSE | 21 - apps/mobile/README.md | 82 + apps/mobile/app.json | 26 +- apps/mobile/eas.json | 21 + apps/mobile/index.ts | 8 - apps/mobile/package-lock.json | 10015 ++++++++++++++++ apps/mobile/package.json | 47 +- apps/mobile/src/app/(tabs)/_layout.tsx | 43 + apps/mobile/src/app/(tabs)/index.tsx | 209 + apps/mobile/src/app/(tabs)/more.tsx | 90 + apps/mobile/src/app/(tabs)/queue.tsx | 299 + apps/mobile/src/app/(tabs)/search.tsx | 207 + apps/mobile/src/app/_layout.tsx | 48 + apps/mobile/src/app/album/[id].tsx | 165 + apps/mobile/src/app/artist/[id].tsx | 183 + apps/mobile/src/app/container/[id].tsx | 87 + apps/mobile/src/app/create-playlist.tsx | 84 + apps/mobile/src/app/discover.tsx | 169 + apps/mobile/src/app/display-name.tsx | 108 + apps/mobile/src/app/feedback.tsx | 56 + apps/mobile/src/app/leaderboard.tsx | 242 + apps/mobile/src/app/lyrics.tsx | 115 + apps/mobile/src/app/player.tsx | 201 + apps/mobile/src/app/playlist/[id].tsx | 341 + apps/mobile/src/app/profile/[userName].tsx | 268 + apps/mobile/src/app/queuedle.tsx | 260 + apps/mobile/src/app/rooms.tsx | 49 + apps/mobile/src/app/spike.tsx | 6 + .../src/core/__tests__/createCore.test.ts | 381 + apps/mobile/src/core/__tests__/events.test.ts | 72 + .../src/core/__tests__/loginUrl.test.ts | 17 + .../mobile/src/core/__tests__/session.test.ts | 151 + apps/mobile/src/core/createCore.ts | 395 + apps/mobile/src/core/events.ts | 35 + apps/mobile/src/core/loginUrl.ts | 6 + apps/mobile/src/core/session.ts | 107 + apps/mobile/src/logic/AddToQueueContext.tsx | 19 + apps/mobile/src/logic/AddToQueueProvider.tsx | 14 + apps/mobile/src/logic/AutoplayContext.tsx | 9 + apps/mobile/src/logic/RemoteContext.tsx | 18 + apps/mobile/src/logic/context/AppContext.tsx | 32 + apps/mobile/src/logic/context/ToastContext.ts | 15 + .../context/__tests__/AppContext.test.tsx | 45 + .../__tests__/useAddToQueueImpl.test.tsx | 235 + .../hooks/__tests__/useAlbumBrowse.test.ts | 168 + .../hooks/__tests__/useArtistBrowse.test.ts | 103 + .../hooks/__tests__/useAttribution.test.ts | 113 + .../src/logic/hooks/__tests__/useAuth.test.ts | 56 + .../logic/hooks/__tests__/useAutoplay.test.ts | 288 + .../__tests__/useContainerBrowse.test.ts | 58 + .../hooks/__tests__/useDailyGame.test.ts | 293 + .../logic/hooks/__tests__/useDjSet.test.ts | 69 + .../hooks/__tests__/useDominantColor.test.ts | 51 + .../logic/hooks/__tests__/useGroups.test.ts | 56 + .../hooks/__tests__/useNowPlaying.test.ts | 142 + .../hooks/__tests__/useOfficePresence.test.ts | 49 + .../logic/hooks/__tests__/useOpenItem.test.ts | 62 + .../logic/hooks/__tests__/usePlayback.test.ts | 469 + .../hooks/__tests__/usePlaylistBrowse.test.ts | 145 + .../hooks/__tests__/usePlaylists.test.ts | 178 + .../logic/hooks/__tests__/useQueue.test.ts | 200 + .../hooks/__tests__/useQueueTrack.test.ts | 148 + .../hooks/__tests__/useSkinnyMode.test.ts | 16 + .../logic/hooks/__tests__/useStats.test.ts | 44 + .../hooks/__tests__/useTrackDetails.test.ts | 151 + apps/mobile/src/logic/hooks/useAlbumBrowse.ts | 126 + .../mobile/src/logic/hooks/useArtistBrowse.ts | 140 + apps/mobile/src/logic/hooks/useAttribution.ts | Bin 0 -> 3050 bytes apps/mobile/src/logic/hooks/useAuth.ts | 13 + apps/mobile/src/logic/hooks/useAutoplay.ts | 244 + .../src/logic/hooks/useContainerBrowse.ts | 23 + apps/mobile/src/logic/hooks/useDailyGame.ts | 262 + .../src/logic/hooks/useDjDiscoveries.ts | 81 + apps/mobile/src/logic/hooks/useDjSet.ts | 38 + .../src/logic/hooks/useDominantColor.ts | 73 + .../src/logic/hooks/useGeniusAlbumYear.ts | 23 + .../src/logic/hooks/useGeniusDescription.ts | 25 + apps/mobile/src/logic/hooks/useGroups.ts | 14 + apps/mobile/src/logic/hooks/useImage.ts | 4 + apps/mobile/src/logic/hooks/useLyrics.ts | 61 + apps/mobile/src/logic/hooks/useNowPlaying.ts | 74 + .../src/logic/hooks/useOfficePresence.ts | 25 + apps/mobile/src/logic/hooks/useOpenItem.ts | 20 + apps/mobile/src/logic/hooks/usePlayback.ts | 220 + .../src/logic/hooks/usePlaylistBrowse.ts | 67 + apps/mobile/src/logic/hooks/usePlaylists.ts | 134 + .../src/logic/hooks/usePrefetchNextLyrics.ts | 21 + apps/mobile/src/logic/hooks/useQueue.ts | 66 + apps/mobile/src/logic/hooks/useQueueTrack.ts | 46 + .../src/logic/hooks/useRecentlyPlayed.ts | 91 + .../src/logic/hooks/useResolveAndOpen.ts | 70 + apps/mobile/src/logic/hooks/useSkinnyMode.ts | 24 + apps/mobile/src/logic/hooks/useStats.ts | 12 + .../mobile/src/logic/hooks/useTrackDetails.ts | 83 + apps/mobile/src/logic/hooks/useUserProfile.ts | 19 + apps/mobile/src/logic/hooks/useUserStats.ts | 44 + apps/mobile/src/logic/hooks/useUsers.ts | 12 + .../logic/lib/__tests__/detailLogic.test.ts | 115 + .../lib/__tests__/favouriteTrack.test.ts | 58 + .../lib/__tests__/identityHelpers.test.ts | 28 + .../logic/lib/__tests__/itemHelpers.test.ts | 513 + .../logic/lib/__tests__/mediaLogic.test.ts | 132 + .../src/logic/lib/__tests__/prefetch.test.ts | 81 + .../lib/__tests__/queryPersistence.test.ts | 28 + .../logic/lib/__tests__/queueHelpers.test.ts | 126 + .../logic/lib/__tests__/queueLogic.test.ts | 95 + .../logic/lib/__tests__/queuedleLogic.test.ts | 48 + .../lib/__tests__/recentSearches.test.ts | 80 + .../logic/lib/__tests__/socialFormat.test.ts | 48 + .../src/logic/lib/__tests__/sonosApi.test.ts | 358 + apps/mobile/src/logic/lib/avatarImage.ts | 37 + apps/mobile/src/logic/lib/itemHelpers.ts | 367 + apps/mobile/src/logic/lib/playlistColor.ts | 9 + apps/mobile/src/logic/lib/prefetch.ts | 75 + apps/mobile/src/logic/lib/queryPersistence.ts | 18 + apps/mobile/src/logic/lib/queueHelpers.ts | 38 + apps/mobile/src/logic/lib/queuedleLogic.ts | 99 + apps/mobile/src/logic/lib/recentSearches.ts | 64 + apps/mobile/src/logic/lib/searchRules.ts | 4 + apps/mobile/src/logic/lib/socialFormat.ts | 65 + apps/mobile/src/logic/lib/sonosApi.ts | 166 + apps/mobile/src/logic/lib/ytmSections.ts | 70 + .../src/logic/providers/AudioProvider.ts | 37 + apps/mobile/src/logic/providers/index.ts | 10 + .../logic/providers/sonos/SonosProvider.ts | 180 + apps/mobile/src/logic/test/setup.ts | 99 + .../mobile/src/logic/types/AlbumResponse.d.ts | 82 + .../src/logic/types/ArtistResponse.d.ts | 74 + .../src/logic/types/PlaylistResponse.d.ts | 127 + .../mobile/src/logic/types/ServiceSearch.d.ts | 314 + apps/mobile/src/logic/types/provider.ts | 40 + apps/mobile/src/logic/types/sonos.ts | 112 + apps/mobile/src/logic/useAddToQueueImpl.ts | 294 + apps/mobile/src/logic/useRemote.ts | 118 + .../src/office/__tests__/azureApi.test.ts | 234 + .../src/office/__tests__/config.test.ts | 81 + .../src/office/__tests__/genius.test.ts | 144 + .../office/__tests__/graphPresence.test.ts | 122 + .../office/__tests__/officePresence.test.ts | 71 + .../src/office/__tests__/pubsub.test.ts | 300 + .../src/office/__tests__/telemetry.test.ts | 123 + apps/mobile/src/office/azureApi.ts | 322 + apps/mobile/src/office/config.ts | 66 + apps/mobile/src/office/genius.ts | 125 + apps/mobile/src/office/graphPresence.ts | 154 + apps/mobile/src/office/officePresence.ts | 41 + apps/mobile/src/office/pubsub.ts | 291 + apps/mobile/src/office/telemetry.ts | 171 + apps/mobile/src/platform/AppGate.tsx | 67 + apps/mobile/src/platform/SonosLogin.tsx | 39 + apps/mobile/src/platform/core.ts | 18 + apps/mobile/src/platform/createPlatform.ts | 32 + apps/mobile/src/platform/queryPersister.ts | 36 + apps/mobile/src/platform/secureStore.ts | 20 + apps/mobile/src/platform/sockets.ts | 18 + apps/mobile/src/platform/sonosCookie.ts | 12 + .../src/platform/useConnectionStatus.ts | 10 + .../src/sonos/__tests__/queueOps.test.ts | 179 + .../src/sonos/__tests__/sonosClient.test.ts | 282 + .../src/sonos/__tests__/wsSession.test.ts | 512 + apps/mobile/src/sonos/constants.ts | 9 + apps/mobile/src/sonos/operations.ts | 71 + apps/mobile/src/sonos/protocol.ts | 102 + apps/mobile/src/sonos/queueOps.ts | 147 + apps/mobile/src/sonos/sonosClient.ts | 267 + apps/mobile/src/sonos/types.ts | 28 + apps/mobile/src/sonos/wsClient.ts | 101 + apps/mobile/src/sonos/wsSession.ts | 497 + apps/mobile/src/spike/SpikeScreen.tsx | 214 + apps/mobile/src/types/globals.d.ts | 474 + apps/mobile/src/ui/Art.tsx | 40 + apps/mobile/src/ui/ConnectionBanner.tsx | 55 + apps/mobile/src/ui/ImagePrefetcher.tsx | 47 + apps/mobile/src/ui/MiniPlayer.tsx | 69 + apps/mobile/src/ui/Slider.tsx | 81 + apps/mobile/src/ui/SwipeRow.tsx | 96 + apps/mobile/src/ui/Toast.tsx | 55 + apps/mobile/src/ui/detail/BrowseTrackRow.tsx | 48 + apps/mobile/src/ui/detail/DetailChrome.tsx | 95 + apps/mobile/src/ui/detail/GeniusText.tsx | 40 + apps/mobile/src/ui/detail/detailLogic.ts | 234 + apps/mobile/src/ui/haptics.ts | 22 + apps/mobile/src/ui/imagePrefetch.ts | 12 + apps/mobile/src/ui/media/ActionSheet.tsx | 180 + apps/mobile/src/ui/media/components.tsx | 292 + apps/mobile/src/ui/media/index.ts | 5 + apps/mobile/src/ui/media/itemLogic.ts | 59 + apps/mobile/src/ui/media/routes.ts | 110 + apps/mobile/src/ui/media/useDebouncedValue.ts | 11 + apps/mobile/src/ui/media/useRecentSearches.ts | 23 + .../src/ui/nowplaying/TintBackground.tsx | 35 + apps/mobile/src/ui/nowplaying/colorMath.ts | 42 + .../src/ui/nowplaying/favouriteTrack.ts | 45 + .../ui/nowplaying/registerColorExtractor.ts | 20 + .../src/ui/nowplaying/useFavouriteTrack.ts | 46 + apps/mobile/src/ui/queue/DjSection.tsx | 48 + apps/mobile/src/ui/queue/QueueRow.tsx | 103 + apps/mobile/src/ui/queue/SelectionBar.tsx | 32 + apps/mobile/src/ui/queue/queueLogic.ts | 126 + apps/mobile/src/ui/social/common.tsx | 92 + .../src/ui/social/identity/IdentityPicker.tsx | 114 + .../src/ui/social/identity/identityHelpers.ts | 44 + apps/mobile/src/ui/social/imageResizer.ts | 27 + .../src/ui/social/queuedle/GameScreens.tsx | 178 + .../mobile/src/ui/social/queuedle/Results.tsx | 126 + .../src/ui/social/useRequireDisplayName.ts | 24 + apps/mobile/src/ui/theme.ts | 29 + apps/mobile/vitest.config.ts | 28 + 211 files changed, 32307 insertions(+), 65 deletions(-) create mode 100644 apps/mobile/.env.example delete mode 100644 apps/mobile/App.tsx delete mode 100644 apps/mobile/LICENSE create mode 100644 apps/mobile/README.md create mode 100644 apps/mobile/eas.json delete mode 100644 apps/mobile/index.ts create mode 100644 apps/mobile/package-lock.json create mode 100644 apps/mobile/src/app/(tabs)/_layout.tsx create mode 100644 apps/mobile/src/app/(tabs)/index.tsx create mode 100644 apps/mobile/src/app/(tabs)/more.tsx create mode 100644 apps/mobile/src/app/(tabs)/queue.tsx create mode 100644 apps/mobile/src/app/(tabs)/search.tsx create mode 100644 apps/mobile/src/app/_layout.tsx create mode 100644 apps/mobile/src/app/album/[id].tsx create mode 100644 apps/mobile/src/app/artist/[id].tsx create mode 100644 apps/mobile/src/app/container/[id].tsx create mode 100644 apps/mobile/src/app/create-playlist.tsx create mode 100644 apps/mobile/src/app/discover.tsx create mode 100644 apps/mobile/src/app/display-name.tsx create mode 100644 apps/mobile/src/app/feedback.tsx create mode 100644 apps/mobile/src/app/leaderboard.tsx create mode 100644 apps/mobile/src/app/lyrics.tsx create mode 100644 apps/mobile/src/app/player.tsx create mode 100644 apps/mobile/src/app/playlist/[id].tsx create mode 100644 apps/mobile/src/app/profile/[userName].tsx create mode 100644 apps/mobile/src/app/queuedle.tsx create mode 100644 apps/mobile/src/app/rooms.tsx create mode 100644 apps/mobile/src/app/spike.tsx create mode 100644 apps/mobile/src/core/__tests__/createCore.test.ts create mode 100644 apps/mobile/src/core/__tests__/events.test.ts create mode 100644 apps/mobile/src/core/__tests__/loginUrl.test.ts create mode 100644 apps/mobile/src/core/__tests__/session.test.ts create mode 100644 apps/mobile/src/core/createCore.ts create mode 100644 apps/mobile/src/core/events.ts create mode 100644 apps/mobile/src/core/loginUrl.ts create mode 100644 apps/mobile/src/core/session.ts create mode 100644 apps/mobile/src/logic/AddToQueueContext.tsx create mode 100644 apps/mobile/src/logic/AddToQueueProvider.tsx create mode 100644 apps/mobile/src/logic/AutoplayContext.tsx create mode 100644 apps/mobile/src/logic/RemoteContext.tsx create mode 100644 apps/mobile/src/logic/context/AppContext.tsx create mode 100644 apps/mobile/src/logic/context/ToastContext.ts create mode 100644 apps/mobile/src/logic/context/__tests__/AppContext.test.tsx create mode 100644 apps/mobile/src/logic/hooks/__tests__/useAddToQueueImpl.test.tsx create mode 100644 apps/mobile/src/logic/hooks/__tests__/useAlbumBrowse.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useArtistBrowse.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useAttribution.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useAuth.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useAutoplay.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useContainerBrowse.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useDailyGame.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useDjSet.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useDominantColor.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useGroups.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useNowPlaying.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useOfficePresence.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useOpenItem.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/usePlayback.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/usePlaylistBrowse.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/usePlaylists.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useQueue.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useQueueTrack.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useSkinnyMode.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useStats.test.ts create mode 100644 apps/mobile/src/logic/hooks/__tests__/useTrackDetails.test.ts create mode 100644 apps/mobile/src/logic/hooks/useAlbumBrowse.ts create mode 100644 apps/mobile/src/logic/hooks/useArtistBrowse.ts create mode 100644 apps/mobile/src/logic/hooks/useAttribution.ts create mode 100644 apps/mobile/src/logic/hooks/useAuth.ts create mode 100644 apps/mobile/src/logic/hooks/useAutoplay.ts create mode 100644 apps/mobile/src/logic/hooks/useContainerBrowse.ts create mode 100644 apps/mobile/src/logic/hooks/useDailyGame.ts create mode 100644 apps/mobile/src/logic/hooks/useDjDiscoveries.ts create mode 100644 apps/mobile/src/logic/hooks/useDjSet.ts create mode 100644 apps/mobile/src/logic/hooks/useDominantColor.ts create mode 100644 apps/mobile/src/logic/hooks/useGeniusAlbumYear.ts create mode 100644 apps/mobile/src/logic/hooks/useGeniusDescription.ts create mode 100644 apps/mobile/src/logic/hooks/useGroups.ts create mode 100644 apps/mobile/src/logic/hooks/useImage.ts create mode 100644 apps/mobile/src/logic/hooks/useLyrics.ts create mode 100644 apps/mobile/src/logic/hooks/useNowPlaying.ts create mode 100644 apps/mobile/src/logic/hooks/useOfficePresence.ts create mode 100644 apps/mobile/src/logic/hooks/useOpenItem.ts create mode 100644 apps/mobile/src/logic/hooks/usePlayback.ts create mode 100644 apps/mobile/src/logic/hooks/usePlaylistBrowse.ts create mode 100644 apps/mobile/src/logic/hooks/usePlaylists.ts create mode 100644 apps/mobile/src/logic/hooks/usePrefetchNextLyrics.ts create mode 100644 apps/mobile/src/logic/hooks/useQueue.ts create mode 100644 apps/mobile/src/logic/hooks/useQueueTrack.ts create mode 100644 apps/mobile/src/logic/hooks/useRecentlyPlayed.ts create mode 100644 apps/mobile/src/logic/hooks/useResolveAndOpen.ts create mode 100644 apps/mobile/src/logic/hooks/useSkinnyMode.ts create mode 100644 apps/mobile/src/logic/hooks/useStats.ts create mode 100644 apps/mobile/src/logic/hooks/useTrackDetails.ts create mode 100644 apps/mobile/src/logic/hooks/useUserProfile.ts create mode 100644 apps/mobile/src/logic/hooks/useUserStats.ts create mode 100644 apps/mobile/src/logic/hooks/useUsers.ts create mode 100644 apps/mobile/src/logic/lib/__tests__/detailLogic.test.ts create mode 100644 apps/mobile/src/logic/lib/__tests__/favouriteTrack.test.ts create mode 100644 apps/mobile/src/logic/lib/__tests__/identityHelpers.test.ts create mode 100644 apps/mobile/src/logic/lib/__tests__/itemHelpers.test.ts create mode 100644 apps/mobile/src/logic/lib/__tests__/mediaLogic.test.ts create mode 100644 apps/mobile/src/logic/lib/__tests__/prefetch.test.ts create mode 100644 apps/mobile/src/logic/lib/__tests__/queryPersistence.test.ts create mode 100644 apps/mobile/src/logic/lib/__tests__/queueHelpers.test.ts create mode 100644 apps/mobile/src/logic/lib/__tests__/queueLogic.test.ts create mode 100644 apps/mobile/src/logic/lib/__tests__/queuedleLogic.test.ts create mode 100644 apps/mobile/src/logic/lib/__tests__/recentSearches.test.ts create mode 100644 apps/mobile/src/logic/lib/__tests__/socialFormat.test.ts create mode 100644 apps/mobile/src/logic/lib/__tests__/sonosApi.test.ts create mode 100644 apps/mobile/src/logic/lib/avatarImage.ts create mode 100644 apps/mobile/src/logic/lib/itemHelpers.ts create mode 100644 apps/mobile/src/logic/lib/playlistColor.ts create mode 100644 apps/mobile/src/logic/lib/prefetch.ts create mode 100644 apps/mobile/src/logic/lib/queryPersistence.ts create mode 100644 apps/mobile/src/logic/lib/queueHelpers.ts create mode 100644 apps/mobile/src/logic/lib/queuedleLogic.ts create mode 100644 apps/mobile/src/logic/lib/recentSearches.ts create mode 100644 apps/mobile/src/logic/lib/searchRules.ts create mode 100644 apps/mobile/src/logic/lib/socialFormat.ts create mode 100644 apps/mobile/src/logic/lib/sonosApi.ts create mode 100644 apps/mobile/src/logic/lib/ytmSections.ts create mode 100644 apps/mobile/src/logic/providers/AudioProvider.ts create mode 100644 apps/mobile/src/logic/providers/index.ts create mode 100644 apps/mobile/src/logic/providers/sonos/SonosProvider.ts create mode 100644 apps/mobile/src/logic/test/setup.ts create mode 100644 apps/mobile/src/logic/types/AlbumResponse.d.ts create mode 100644 apps/mobile/src/logic/types/ArtistResponse.d.ts create mode 100644 apps/mobile/src/logic/types/PlaylistResponse.d.ts create mode 100644 apps/mobile/src/logic/types/ServiceSearch.d.ts create mode 100644 apps/mobile/src/logic/types/provider.ts create mode 100644 apps/mobile/src/logic/types/sonos.ts create mode 100644 apps/mobile/src/logic/useAddToQueueImpl.ts create mode 100644 apps/mobile/src/logic/useRemote.ts create mode 100644 apps/mobile/src/office/__tests__/azureApi.test.ts create mode 100644 apps/mobile/src/office/__tests__/config.test.ts create mode 100644 apps/mobile/src/office/__tests__/genius.test.ts create mode 100644 apps/mobile/src/office/__tests__/graphPresence.test.ts create mode 100644 apps/mobile/src/office/__tests__/officePresence.test.ts create mode 100644 apps/mobile/src/office/__tests__/pubsub.test.ts create mode 100644 apps/mobile/src/office/__tests__/telemetry.test.ts create mode 100644 apps/mobile/src/office/azureApi.ts create mode 100644 apps/mobile/src/office/config.ts create mode 100644 apps/mobile/src/office/genius.ts create mode 100644 apps/mobile/src/office/graphPresence.ts create mode 100644 apps/mobile/src/office/officePresence.ts create mode 100644 apps/mobile/src/office/pubsub.ts create mode 100644 apps/mobile/src/office/telemetry.ts create mode 100644 apps/mobile/src/platform/AppGate.tsx create mode 100644 apps/mobile/src/platform/SonosLogin.tsx create mode 100644 apps/mobile/src/platform/core.ts create mode 100644 apps/mobile/src/platform/createPlatform.ts create mode 100644 apps/mobile/src/platform/queryPersister.ts create mode 100644 apps/mobile/src/platform/secureStore.ts create mode 100644 apps/mobile/src/platform/sockets.ts create mode 100644 apps/mobile/src/platform/sonosCookie.ts create mode 100644 apps/mobile/src/platform/useConnectionStatus.ts create mode 100644 apps/mobile/src/sonos/__tests__/queueOps.test.ts create mode 100644 apps/mobile/src/sonos/__tests__/sonosClient.test.ts create mode 100644 apps/mobile/src/sonos/__tests__/wsSession.test.ts create mode 100644 apps/mobile/src/sonos/constants.ts create mode 100644 apps/mobile/src/sonos/operations.ts create mode 100644 apps/mobile/src/sonos/protocol.ts create mode 100644 apps/mobile/src/sonos/queueOps.ts create mode 100644 apps/mobile/src/sonos/sonosClient.ts create mode 100644 apps/mobile/src/sonos/types.ts create mode 100644 apps/mobile/src/sonos/wsClient.ts create mode 100644 apps/mobile/src/sonos/wsSession.ts create mode 100644 apps/mobile/src/spike/SpikeScreen.tsx create mode 100644 apps/mobile/src/types/globals.d.ts create mode 100644 apps/mobile/src/ui/Art.tsx create mode 100644 apps/mobile/src/ui/ConnectionBanner.tsx create mode 100644 apps/mobile/src/ui/ImagePrefetcher.tsx create mode 100644 apps/mobile/src/ui/MiniPlayer.tsx create mode 100644 apps/mobile/src/ui/Slider.tsx create mode 100644 apps/mobile/src/ui/SwipeRow.tsx create mode 100644 apps/mobile/src/ui/Toast.tsx create mode 100644 apps/mobile/src/ui/detail/BrowseTrackRow.tsx create mode 100644 apps/mobile/src/ui/detail/DetailChrome.tsx create mode 100644 apps/mobile/src/ui/detail/GeniusText.tsx create mode 100644 apps/mobile/src/ui/detail/detailLogic.ts create mode 100644 apps/mobile/src/ui/haptics.ts create mode 100644 apps/mobile/src/ui/imagePrefetch.ts create mode 100644 apps/mobile/src/ui/media/ActionSheet.tsx create mode 100644 apps/mobile/src/ui/media/components.tsx create mode 100644 apps/mobile/src/ui/media/index.ts create mode 100644 apps/mobile/src/ui/media/itemLogic.ts create mode 100644 apps/mobile/src/ui/media/routes.ts create mode 100644 apps/mobile/src/ui/media/useDebouncedValue.ts create mode 100644 apps/mobile/src/ui/media/useRecentSearches.ts create mode 100644 apps/mobile/src/ui/nowplaying/TintBackground.tsx create mode 100644 apps/mobile/src/ui/nowplaying/colorMath.ts create mode 100644 apps/mobile/src/ui/nowplaying/favouriteTrack.ts create mode 100644 apps/mobile/src/ui/nowplaying/registerColorExtractor.ts create mode 100644 apps/mobile/src/ui/nowplaying/useFavouriteTrack.ts create mode 100644 apps/mobile/src/ui/queue/DjSection.tsx create mode 100644 apps/mobile/src/ui/queue/QueueRow.tsx create mode 100644 apps/mobile/src/ui/queue/SelectionBar.tsx create mode 100644 apps/mobile/src/ui/queue/queueLogic.ts create mode 100644 apps/mobile/src/ui/social/common.tsx create mode 100644 apps/mobile/src/ui/social/identity/IdentityPicker.tsx create mode 100644 apps/mobile/src/ui/social/identity/identityHelpers.ts create mode 100644 apps/mobile/src/ui/social/imageResizer.ts create mode 100644 apps/mobile/src/ui/social/queuedle/GameScreens.tsx create mode 100644 apps/mobile/src/ui/social/queuedle/Results.tsx create mode 100644 apps/mobile/src/ui/social/useRequireDisplayName.ts create mode 100644 apps/mobile/src/ui/theme.ts create mode 100644 apps/mobile/vitest.config.ts diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example new file mode 100644 index 0000000..21c42a4 --- /dev/null +++ b/apps/mobile/.env.example @@ -0,0 +1,7 @@ +# Optional integrations, baked into the bundle (EXPO_PUBLIC_*), like the desktop's build-env.json. +# Azure Functions base URL (defaults to the shared truetunes-fn app) +# EXPO_PUBLIC_PUBSUB_FUNCTION_URL=https://truetunes-fn.azurewebsites.net +# Genius lyrics/artist bios; features quietly disable when unset +EXPO_PUBLIC_GENIUS_ACCESS_TOKEN= +# Application Insights telemetry; a complete no-op when unset +EXPO_PUBLIC_APPINSIGHTS_CONNECTION_STRING= diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index d914c32..f24f9b0 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -39,3 +39,11 @@ yarn-error.* # generated native folders /ios /android + +# local logs +build.log +metro.log +.env +prebuild.log +coverage/ +junit.xml diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx deleted file mode 100644 index 0329d0c..0000000 --- a/apps/mobile/App.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { StatusBar } from 'expo-status-bar'; -import { StyleSheet, Text, View } from 'react-native'; - -export default function App() { - return ( - - Open up App.tsx to start working on your app! - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, -}); diff --git a/apps/mobile/LICENSE b/apps/mobile/LICENSE deleted file mode 100644 index 30b20e3..0000000 --- a/apps/mobile/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 0000000..987d17d --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,82 @@ +# True Tunes — Android + +Sideloaded Android client for True Tunes (a Sonos web-player client). Sibling of the desktop +app in `R:\TrueTunes`; see `R:\TrueTunes\docs\android-plan.md` (branch `plan/android-react-native`) +for the full plan. **Distribution is sideload-only (APK), never the Play Store.** + +## Status + +Runs end to end on a Pixel 10 Pro XL: Sonos sign-in (WebView) or restored session, WebSocket bootstrap, +groups, queue, playback control, Home/Search/Queue/More tabs, Now Playing, room picker, and the +album/artist/container/playlist detail screens. All of it is untested on a device beyond Home, Queue and +the shell - see "Verify on device" below. + +### Layout + +- `src/sonos/` - Sonos client, WebSocket session, queue reorder (ported from desktop `main.ts`, unit tested) +- `src/office/` - Azure Functions client, pubsub, Genius, Graph presence, telemetry +- `src/core/` - session/auth manager, event hub, `createCore` (composes everything into a `window.sonos` bridge) +- `src/platform/` - React Native adapters (SecureStore, sockets, cookies, login WebView) and the auth gate +- `src/logic/` - hooks/lib/providers ported from the desktop renderer, plus `useRemote` and add-to-queue +- `src/ui/` + `src/app/` - screens (expo-router) and shared components +- `src/app/spike.tsx` - the Phase 0 connectivity spike, kept as a debug route + +### Not done / needs input + +- **Entra sign-in**: needs an Android platform + redirect URI on the app registration, then an `EntraProvider` + (see `src/core/createCore.ts`). Until then: no display-name claim, no office presence, Home "Recently + Played" and playlists stay empty (they key off the display name). +- **Genius** (lyrics text, artist bios, album year) and **App Insights**: set `EXPO_PUBLIC_*` in `.env` + (see `.env.example`). +- Lock-screen/notification controls (needs a MediaSession + foreground service). +- Queue: multi-item drag; Album/artist headers have no colour tint (needs a native pixel sampler). + +### Verify on device + +Search, Now Playing (seek/volume gestures), room picker, queue drag-reorder and multi-select, add-to-queue ++ attribution, lyrics, leaderboard, profile image upload, Queuedle, and every modal presentation. + +### Spike results (Pixel 10 Pro XL, Android, 2026-09-29) + +| Check | Result | +| --- | --- | +| Sonos sign-in in an embedded WebView | PASS - normal Okta flow (login.sonos.com) works, not blocked | +| Read HttpOnly `__Secure-next-auth.session-token` natively | PASS - visible via `@preeternal/react-native-cookie-manager`; cookie `expires` ~10 days out | +| RN `fetch` `/api/mfe` with manual `Cookie` + spoofed UA | PASS - 200, ticket found | +| WebSocket with custom `Origin` + `v1.api.smartspeaker.audio` | PASS - opened, subprotocol negotiated | +| Desktop bootstrap (households -> devices -> playbackExtended) | PASS - groups discovered, live `PLAYBACK_STATE_*` pushes | +| `pause` command | PASS - acknowledged | +| `play` command | PASS - acknowledged; live state `BUFFERING -> PLAYING`, then `pause` -> `PAUSED` | +| Token survives app restart (SecureStore) | PASS - after force-stop, restored token drove `/api/mfe` + full WS bootstrap (WebView cookie jar also still held the cookie, so this proves SecureStore holds a valid token, not that it is the only working source) | +| Ported client (`src/sonos/sonosClient.ts`) vs real Sonos | PASS - `getQueueResources`, `getFavoriteResources`, on-demand service discovery (serviceId 72711). `getGroups` returns a non-JSON 200 but is unused on desktop (groups come from the WS). | +| Session lifetime / silent refresh | open - watch whether the cookie slides or hard-expires at ~10 days | + +## Building locally (command-line tools only, no Android Studio) + +One-time setup (already done on the dev machine, all user-level, no admin): + +- JDK 17 (Microsoft OpenJDK zip) -> `%LOCALAPPDATA%\Programs\jdk17`, `JAVA_HOME` set +- Android command-line tools -> `%LOCALAPPDATA%\Android\Sdk`, `ANDROID_HOME` set, `platform-tools` on PATH +- SDK packages: `platforms;android-36`, `build-tools;36.0.0`, `ndk;27.1.12297006`, `cmake;3.22.1` + (versions come from `node_modules/react-native/gradle/libs.versions.toml`; re-check after an SDK bump). + Note `sdkmanager.bat` splits on `;` in PowerShell - call it via `cmd /c` with quoted package names. + +Build and install (phone needs USB debugging on; `adb devices` must list it): + +```bash +npx expo prebuild --platform android --no-install # generates android/ (gitignored) +cd android && ./gradlew assembleDebug -PreactNativeArchitectures=arm64-v8a +adb install app/build/outputs/apk/debug/app-debug.apk +npx expo start --dev-client # then open the app on the phone +``` + +This is Continuous Native Generation: `android/` is generated - never edit it by hand. For a standalone +sideload APK use `./gradlew assembleRelease` (configure a real keystore before distributing). +We chose local builds over EAS so source and signing keys stay on our machines. + +## Notes + +- Expo SDK 57 / RN 0.86 is New Architecture only. `@react-native-cookies/cookies` is unmaintained and + unsupported there, so this uses `@preeternal/react-native-cookie-manager`. +- `src/sonos/` mirrors constants and helpers from `TrueTunes/src/main.ts`. It is meant to become the + shared `core` package once the spike passes. diff --git a/apps/mobile/app.json b/apps/mobile/app.json index f3d12d1..2f0e7fc 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -1,14 +1,11 @@ { "expo": { - "name": "TrueTunes-Android", - "slug": "TrueTunes-Android", + "name": "True Tunes", + "slug": "truetunes-android", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", - "userInterfaceStyle": "light", - "ios": { - "supportsTablet": true - }, + "userInterfaceStyle": "dark", "android": { "adaptiveIcon": { "backgroundColor": "#E6F4FE", @@ -16,10 +13,19 @@ "backgroundImage": "./assets/android-icon-background.png", "monochromeImage": "./assets/android-icon-monochrome.png" }, - "predictiveBackGestureEnabled": false + "predictiveBackGestureEnabled": false, + "package": "uk.co.truenorthit.truetunes" }, - "web": { - "favicon": "./assets/favicon.png" - } + "plugins": [ + "expo-secure-store", + "expo-build-properties", + "@preeternal/react-native-cookie-manager", + "expo-router", + "expo-image" + ], + "platforms": [ + "android" + ], + "scheme": "truetunes" } } diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json new file mode 100644 index 0000000..fb5cf0b --- /dev/null +++ b/apps/mobile/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 16.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, + "sideload": { + "distribution": "internal", + "android": { + "buildType": "apk" + } + } + } +} diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts deleted file mode 100644 index 1d6e981..0000000 --- a/apps/mobile/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { registerRootComponent } from 'expo'; - -import App from './App'; - -// registerRootComponent calls AppRegistry.registerComponent('main', () => App); -// It also ensures that whether you load the app in Expo Go or in a native build, -// the environment is set up appropriately -registerRootComponent(App); diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json new file mode 100644 index 0000000..78db85a --- /dev/null +++ b/apps/mobile/package-lock.json @@ -0,0 +1,10015 @@ +{ + "name": "truetunes-android", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "truetunes-android", + "version": "1.0.0", + "dependencies": { + "@preeternal/react-native-cookie-manager": "^7.0.0", + "@shopify/flash-list": "2.0.2", + "@tanstack/query-async-storage-persister": "^5.104.0", + "@tanstack/react-query": "^5.104.0", + "@tanstack/react-query-persist-client": "^5.104.0", + "expo": "~57.0.26", + "expo-build-properties": "~57.0.22", + "expo-constants": "~57.0.20", + "expo-dev-client": "~57.0.19", + "expo-file-system": "~57.0.7", + "expo-haptics": "~57.0.3", + "expo-image": "~57.0.5", + "expo-image-manipulator": "~57.0.20", + "expo-image-picker": "~57.0.20", + "expo-linear-gradient": "~57.0.2", + "expo-linking": "~57.0.11", + "expo-router": "~57.0.24", + "expo-secure-store": "~57.0.4", + "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.4", + "lucide-react-native": "^1.48.0", + "react": "19.2.3", + "react-native": "0.86.3", + "react-native-draggable-flatlist": "^4.0.3", + "react-native-gesture-handler": "~2.32.0", + "react-native-image-colors": "^2.6.0", + "react-native-reanimated": "4.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", + "react-native-svg": "15.15.4", + "react-native-webview": "13.16.1", + "react-native-worklets": "0.10.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/react": "~19.2.2", + "jsdom": "^30.1.1", + "react-dom": "^19.2.3", + "typescript": "~6.0.3", + "vitest": "^5.0.2" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.2.tgz", + "integrity": "sha512-99DHAnXDB5z6EEK+9GMpVI7Mw4oxj97dY5bpOzMnjADQWxI8rN6TvTduuFLUhUMlS7/CfVZ06tcZsus6cltnNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.1", + "@csstools/css-color-parser": "^4.2.4", + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.2.tgz", + "integrity": "sha512-lSWTBMjAcmu2xn5yEDU7jh6QDV+C8GEKtdJ4pIQhXh26RkKQ7S3FuQlt+zZkUbTdJ4d3XyV1kPI/G0zWXxqaqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.29.7.tgz", + "integrity": "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.29.7.tgz", + "integrity": "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", + "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", + "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.9.tgz", + "integrity": "sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz", + "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz", + "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz", + "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@expo-google-fonts/material-symbols": { + "version": "0.4.48", + "resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.48.tgz", + "integrity": "sha512-aZztj44PyA/sj3TXSwwpLJwqMlTUJ49okbZJYURzEcuERxMQB0KbX7RrLBi8WJnuWz5HFOLlTRxORrJfNnUPNQ==", + "license": "MIT AND Apache-2.0" + }, + "node_modules/@expo/code-signing-certificates": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "license": "MIT", + "dependencies": { + "node-forge": "^1.3.3" + } + }, + "node_modules/@expo/config": { + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-57.0.9.tgz", + "integrity": "sha512-dmzlKraIFxa7wLwV6K7WzI8jp6QZpW6Mc5mGjLimJUFjzh4uQdYaT3m3plEutM5yxBoBEwqzks7l+I/ljCbxAQ==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~57.0.9", + "@expo/config-types": "^57.0.2", + "@expo/json-file": "^11.0.1", + "@expo/require-utils": "^57.0.5", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "node_modules/@expo/config-plugins": { + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.9.tgz", + "integrity": "sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^57.0.2", + "@expo/json-file": "~11.0.1", + "@expo/plist": "^0.8.1", + "@expo/require-utils": "^57.0.5", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/config-types": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-57.0.2.tgz", + "integrity": "sha512-ewW08OonrcRIsRKIlFvvcmmafE5zemb1ocu3HkNwtVPyRtj2w42pZCAkMIROYpcVBaPnc3mDT9UZDzwXWC3i6g==", + "license": "MIT" + }, + "node_modules/@expo/devcert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "license": "MIT", + "dependencies": { + "@expo/sudo-prompt": "^9.3.1", + "debug": "^3.1.0" + } + }, + "node_modules/@expo/devcert/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@expo/devtools": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-57.0.1.tgz", + "integrity": "sha512-GyUf+wFNkbttaX0jR7MZa9bm77U0IrLg6d2AjpxdyoXw/w4abHoXG0oFufwLMgP9zLTd5+Ct4X/ffNUTnlzZgg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/dom-webview": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-57.0.1.tgz", + "integrity": "sha512-lAKsME4SAq+8sf56oN0DX5TBYyruupoRxbWbD2xf9RnKY8y6x8eb9LCE5pxSN0qyWdqnp+0wmyWzDkKboThKAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/env": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.3.tgz", + "integrity": "sha512-M1NXeZCA1mkMkYOyIe7PlyRX0/jqFtMoJgyblnlq/vpCRfmueFT7RnGSQG8uEFDF5WHOFGijAQ3fogPh3/n5Ng==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, + "node_modules/@expo/expo-modules-macros-plugin": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@expo/expo-modules-macros-plugin/-/expo-modules-macros-plugin-0.6.1.tgz", + "integrity": "sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA==", + "license": "MIT" + }, + "node_modules/@expo/fingerprint": { + "version": "0.20.13", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.20.13.tgz", + "integrity": "sha512-h1x+w+JpNRu88a/5SMZRzJs9YDGR/f7R8ApCr7OdenhqmJ00hFxQMzvxajDLL+1P2Y2g/ND0xksusqvBqWSW5Q==", + "license": "MIT", + "dependencies": { + "@expo/env": "^2.4.3", + "@expo/spawn-async": "^1.8.0", + "arg": "^5.0.2", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "minimatch": "^10.2.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "bin": { + "fingerprint": "bin/cli.js" + } + }, + "node_modules/@expo/image-utils": { + "version": "0.11.5", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.11.5.tgz", + "integrity": "sha512-KPQBTpmpAfy/Vu9y4wPW808/qtZxjYmyJg8cm2QCPAupp+qEWA3b5zmk0ulOwQ9OgeHxuCPgUqWgkwHFo7UsrQ==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^57.0.5", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/inline-modules": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.1.7.tgz", + "integrity": "sha512-Bz/khd1gIJqDkje7t5ejD5e9jFbm4xEJzWSwRKadRo6gruepbw1xJ3Eb+e58OS8fY1ZNQYjzZbspnuph67sN0g==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~57.0.9" + } + }, + "node_modules/@expo/json-file": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.0.1.tgz", + "integrity": "sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/local-build-cache-provider": { + "version": "57.0.8", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-57.0.8.tgz", + "integrity": "sha512-SEdE0pAQrr90bRh3MNR0ZuwoIBw390cYdFgbn7Vk0Mtm9EHaBfP7kYj+2hXnXZJgOEm3/JMtfoD3rV7rWA9FGg==", + "license": "MIT", + "dependencies": { + "@expo/config": "~57.0.9", + "chalk": "^4.1.2" + } + }, + "node_modules/@expo/log-box": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-57.0.4.tgz", + "integrity": "sha512-IxwS9s1L2muj8mj8AQSuiy7u8OFJdc02NRFo2me/Tj6DiaeG5SREqmpBE4rQpR2cadqSg5jl8Qab8Cjie616dg==", + "license": "MIT", + "dependencies": { + "@expo/dom-webview": "^57.0.1", + "anser": "^1.4.9", + "stacktrace-parser": "^0.1.10" + }, + "peerDependencies": { + "@expo/dom-webview": "^57.0.1", + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/metro": { + "version": "56.0.2", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-56.0.2.tgz", + "integrity": "sha512-Ld5AeYMCCDa8bLeWhfuLbZFFjlV3f6ORqyPz2glGh6RltIngMuLf9BTC2yvHFjkKuGxL5SynijmA8xmNNWn5iA==", + "license": "MIT", + "dependencies": { + "metro": "0.84.5", + "metro-babel-transformer": "0.84.5", + "metro-cache": "0.84.5", + "metro-cache-key": "0.84.5", + "metro-config": "0.84.5", + "metro-core": "0.84.5", + "metro-file-map": "0.84.5", + "metro-minify-terser": "0.84.5", + "metro-resolver": "0.84.5", + "metro-runtime": "0.84.5", + "metro-source-map": "0.84.5", + "metro-symbolicate": "0.84.5", + "metro-transform-plugins": "0.84.5", + "metro-transform-worker": "0.84.5" + } + }, + "node_modules/@expo/metro-file-map": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-57.0.3.tgz", + "integrity": "sha512-1OXy+uPYY5uc7Tm4VBsd2NRn+3wHhqeqNuEO/Xo4kmYgv8FjYgUAc+bUXON9FpC2ikcLn4EVlGM9ce2exx9Mlg==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "fb-watchman": "^2.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + } + }, + "node_modules/@expo/metro-runtime": { + "version": "57.0.16", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.16.tgz", + "integrity": "sha512-x43WgZoICpp37BhDV7vixe0bNxbVPPGaBLHYc98ZJ8MU+UC2dk39Y2XvkfYMRRbAUSIkCIJiwdERnptlPg0Ckw==", + "license": "MIT", + "dependencies": { + "@expo/log-box": "^57.0.4", + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" + }, + "peerDependencies": { + "@expo/log-box": "^57.0.4", + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@expo/osascript": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.1.tgz", + "integrity": "sha512-Zn03EX6In7ts2lPUW2ESUSkEhEWQN1qqsiXjadtZMJOuZRkMiAg1ZQHuvz9DjByDWNJ2pBwAGyrts9lj9k389g==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.8.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/package-manager": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.13.1.tgz", + "integrity": "sha512-y/K+CaYYpZpNGZhSX4HyLT/vyIunFjNfyoxNysPBCefeLKI/VCx6f9LNPzrxayr3rCYO5bl9O8H+HRQK265Nkg==", + "license": "MIT", + "dependencies": { + "@expo/json-file": "^11.0.1", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" + } + }, + "node_modules/@expo/plist": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.8.1.tgz", + "integrity": "sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/@expo/prebuild-config": { + "version": "57.0.16", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.16.tgz", + "integrity": "sha512-QA8oMZ7y70kjOzw+abhRl9uYC8Nu/abeUTHr0YVxb3pgIC14gc7w543bYtI+5gEDI3q55jKUViIauC76QvZN1w==", + "license": "MIT", + "dependencies": { + "@expo/config": "~57.0.9", + "@expo/config-plugins": "~57.0.9", + "@expo/config-types": "^57.0.2", + "@expo/image-utils": "^0.11.5", + "@expo/json-file": "^11.0.1", + "@react-native/normalize-colors": "0.86.3", + "debug": "^4.3.1", + "expo-modules-autolinking": "~57.0.13", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/require-utils": { + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.5.tgz", + "integrity": "sha512-kTAXj9lDFEIPMsbAOGCGbjBbMF0oi7CqkYM79KOX0DDD9wSwXmlKL1z2h8OwsrBf7mbOo2DjlRvZu4BEjrIxGw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@expo/schema-utils": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-57.0.2.tgz", + "integrity": "sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw==", + "license": "MIT" + }, + "node_modules/@expo/sdk-runtime-versions": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", + "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", + "license": "MIT" + }, + "node_modules/@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", + "license": "MIT" + }, + "node_modules/@expo/ui": { + "version": "57.0.21", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.21.tgz", + "integrity": "sha512-4s1ypGNxJhJv2r1fj3wZo4Aubao0zGnCajCuiaTp8pTNrT+1vR8KvyoFEwp+c7gpKm2D58nGCiGBXhEnmKHtDQ==", + "license": "MIT", + "dependencies": { + "sf-symbols-typescript": "^2.1.0", + "vaul": "^1.1.2" + }, + "peerDependencies": { + "@babel/core": "*", + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-worklets": "*" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-worklets": { + "optional": true + } + } + }, + "node_modules/@expo/xcpretty": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.5.tgz", + "integrity": "sha512-J3eL4n4h5QTwfD0SIz8OIk6/+sOL/hFZAMacgCM07UNlxBQfJipEpIC2AQxvGkbYeStByJ0TVhQAJo+DeNgaSQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "chalk": "^4.1.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "excpretty": "build/cli.js" + } + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jimp/bmp": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.22.12.tgz", + "integrity": "sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g==", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12", + "bmp-js": "^0.1.0" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/core": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-0.22.12.tgz", + "integrity": "sha512-l0RR0dOPyzMKfjUW1uebzueFEDtCOj9fN6pyTYWWOM/VS4BciXQ1VVrJs8pO3kycGYZxncRKhCoygbNr8eEZQA==", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12", + "any-base": "^1.1.0", + "buffer": "^5.2.0", + "exif-parser": "^0.1.12", + "file-type": "^16.5.4", + "isomorphic-fetch": "^3.0.0", + "pixelmatch": "^4.0.2", + "tinycolor2": "^1.6.0" + } + }, + "node_modules/@jimp/custom": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", + "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", + "license": "MIT", + "dependencies": { + "@jimp/core": "^0.22.12" + } + }, + "node_modules/@jimp/gif": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/gif/-/gif-0.22.12.tgz", + "integrity": "sha512-y6BFTJgch9mbor2H234VSjd9iwAhaNf/t3US5qpYIs0TSbAvM02Fbc28IaDETj9+4YB4676sz4RcN/zwhfu1pg==", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12", + "gifwrap": "^0.10.1", + "omggif": "^1.0.9" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/jpeg": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/jpeg/-/jpeg-0.22.12.tgz", + "integrity": "sha512-Rq26XC/uQWaQKyb/5lksCTCxXhtY01NJeBN+dQv5yNYedN0i7iYu+fXEoRsfaJ8xZzjoANH8sns7rVP4GE7d/Q==", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12", + "jpeg-js": "^0.4.4" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", + "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/png": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/png/-/png-0.22.12.tgz", + "integrity": "sha512-Mrp6dr3UTn+aLK8ty/dSKELz+Otdz1v4aAXzV5q53UDD2rbB5joKVJ/ChY310B+eRzNxIovbUF1KVrUsYdE8Hg==", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12", + "pngjs": "^6.0.0" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/png/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@jimp/tiff": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/tiff/-/tiff-0.22.12.tgz", + "integrity": "sha512-E1LtMh4RyJsoCAfAkBRVSYyZDTtLq9p9LUiiYP0vPtXyxX4BiYBUYihTLSBlCQg5nF2e4OpQg7SPrLdJ66u7jg==", + "license": "MIT", + "dependencies": { + "utif2": "^4.0.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/types": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-0.22.12.tgz", + "integrity": "sha512-wwKYzRdElE1MBXFREvCto5s699izFHNVvALUv79GXNbsOVqlwlOxlWJ8DuyOGIXoLP4JW/m30YyuTtfUJgMRMA==", + "license": "MIT", + "dependencies": { + "@jimp/bmp": "^0.22.12", + "@jimp/gif": "^0.22.12", + "@jimp/jpeg": "^0.22.12", + "@jimp/png": "^0.22.12", + "@jimp/tiff": "^0.22.12", + "timm": "^1.6.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/utils": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-0.22.12.tgz", + "integrity": "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.13.3" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@preeternal/react-native-cookie-manager": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@preeternal/react-native-cookie-manager/-/react-native-cookie-manager-7.0.0.tgz", + "integrity": "sha512-xgbzW7Ebz6YO9k5aYAHpMc1Psd0hu67iAoPE6c8UbWbl8d959nsTOMxrT2xBOrYiVKt/cOjmC5W0gcIE+Dly2w==", + "license": "MIT", + "workspaces": [ + "example", + "example-spm" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-native-masked-view/masked-view": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", + "integrity": "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16", + "react-native": ">=0.57" + } + }, + "node_modules/@react-native/assets-registry": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.3.tgz", + "integrity": "sha512-TDhgCZA4wjJg84d5A9swiOQYPIWSKEEVdg9IwMFZDupQzW/F3QoLUrfAJOcalgqTDA9/buTB8awhE3Whwg6u9Q==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.86.3.tgz", + "integrity": "sha512-O6Xza4JBGPIU8J7YbKTyBoYL4thpy8jMW/oaLDWdAyOwYHKIjK47pAL5HUEbOe2bWz2PEKjbYRF2ApkJv1ottQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@react-native/codegen": "0.86.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/babel-preset": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.86.3.tgz", + "integrity": "sha512-/eqs/Hy9RZRcjdcs4wj3Cqmxvtb3NM5g+Uuh1RIvsjynMO8PRsrVWWLWgBcZL/jYUo+ogXd2NFB0W8L5Bg89xw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@react-native/babel-plugin-codegen": "0.86.3", + "babel-plugin-syntax-hermes-parser": "0.36.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.86.3.tgz", + "integrity": "sha512-Ux4jHi0fh+bdtVEcL0gaPLbY56V+SvFUDl/8sRAE1jdb4k+o7fT/4Nc29yz4X+qfjstkSqObQTMBGhdzxH9JvA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.29.0", + "hermes-parser": "0.36.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen/node_modules/hermes-estree": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", + "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", + "license": "MIT" + }, + "node_modules/@react-native/codegen/node_modules/hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", + "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.36.0" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.86.3.tgz", + "integrity": "sha512-qSDL9LQc5mZSZPNczT95WU9YQuPzxBklgON9vLhhqfI0yWIwKInqFx88dQ/uiEXBtf0yossthaQIqA4Ml6bF6g==", + "license": "MIT", + "dependencies": { + "@react-native/dev-middleware": "0.86.3", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.84.3", + "metro-config": "^0.84.3", + "metro-core": "^0.84.3", + "semver": "^7.1.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "0.86.3" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.86.3.tgz", + "integrity": "sha512-TQmeofQ0PcuylhhlleOeuzHYZfbrgm3gayXzowqUEzgRisTm1D40/J3ggqs7XkQi5HP5ZA3n8dHmKL9vIzPcsw==", + "license": "BSD-3-Clause", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/debugger-shell": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.86.3.tgz", + "integrity": "sha512-O4ds+J7xZfxkbih9T+cAGegBdvKSPKYJm/lDgC9CpEjFMkmzWTpVLU3Qsv9sqZuo58z+sGhIcJfPsCFFWHpqbQ==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "debug": "^4.4.0", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.86.3.tgz", + "integrity": "sha512-LiEPTqTg/63bYUnrPyHLfjTDCNhA/+CUqI1+DsA9tYyewtSbULd5awsva6SgE10I+2iMhgKXS3ymkhU/kSCrGA==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.86.3", + "@react-native/debugger-shell": "0.86.3", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.3.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.86.3.tgz", + "integrity": "sha512-lxmx0GqLEWRIpZfpYFXlYVIs3ENQwaW6Vmp6oi29l2GoQJ1wZfFZRdMimDWlGEk8LKfHar3QH3iaPMkTcK9lEQ==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.3.tgz", + "integrity": "sha512-eYIJ0es967+tePBFQDnl/gidVFxLns3fnbiK6rxscQrGodvuUO6hwxpQnfNynJ8MjbbndImXihXjcnJdc7SzJg==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-babel-transformer": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.86.3.tgz", + "integrity": "sha512-0nlwVkG0uT9o72nrGTKUz/IqDnCkM4zE0ihe4r+OJcxH93yBdDm58AXJ3EXZeSjv3osHEFXU7ceajdZiS34mTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@react-native/babel-preset": "0.86.3", + "hermes-parser": "0.36.0", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", + "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", + "license": "MIT", + "peer": true + }, + "node_modules/@react-native/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", + "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", + "license": "MIT", + "peer": true, + "dependencies": { + "hermes-estree": "0.36.0" + } + }, + "node_modules/@react-native/metro-config": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.86.3.tgz", + "integrity": "sha512-qdzDMepV2xdUhsO4XC7idnnbt8K6+Hd59AjeK9y4KS86fxSQq5oL0TAbqUuajDGR7beNQoYTuS6AowN/zP0oaQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@react-native/js-polyfills": "0.86.3", + "@react-native/metro-babel-transformer": "0.86.3", + "metro-config": "^0.84.3", + "metro-runtime": "^0.84.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.86.3.tgz", + "integrity": "sha512-Cv3CDkprb67GrzuaS9BGbBJC/6G4lIw3nyKOHRKTqTTum4bn37y5+R0Z04L8mcbQN85eEohNrRwb7IOM4j6uvg==", + "license": "MIT" + }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.86.3.tgz", + "integrity": "sha512-1j44NEyNn05Ut40vHAmoSWbsIcybFkMAOBTwQt1PrESyfSS+qBoyU1LGIogNva0VIa0rQyEC5PzbA7R4/7Nhyw==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@types/react": "^19.2.0", + "react": "*", + "react-native": "0.86.3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@shopify/flash-list": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.0.2.tgz", + "integrity": "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@babel/runtime": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "license": "MIT" + }, + "node_modules/@tanstack/query-async-storage-persister": { + "version": "5.104.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.104.0.tgz", + "integrity": "sha512-7pz2WgNxcvGKqbmILrmZ8DOQLOoQyqjT9HTWngYoxgjUyEses0O+GOzTjShT68snFq67EA3CFuYJs2Jna+kBIA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.0", + "@tanstack/query-persist-client-core": "5.104.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.104.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.0.tgz", + "integrity": "sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-persist-client-core": { + "version": "5.104.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-5.104.0.tgz", + "integrity": "sha512-7XEoH8+gaARK06Uw6e97WqOfus7dtcrYxA6RUf1+OZArz1Ax7Stlcfas6XRz7V2YiOTAkGKUYsk2y43QUmpiTw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.104.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.0.tgz", + "integrity": "sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-persist-client": { + "version": "5.104.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-5.104.0.tgz", + "integrity": "sha512-ZIw4PQfk/MLEJEyaxg0AQfuT0I+HGSogcWl18GjG+Yy2707TFtf3Or2yZilAJoSzBMdoZr9X2xx6T5cap0PlUg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-persist-client-core": "5.104.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.104.0", + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-test-renderer": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.3.0.tgz", + "integrity": "sha512-TX4p2D4Z2oigM3SXU7+QyLSUnSR0vR1S1Pfxm/qFL9nHQ3jclgi6ioGoWQmlIr1DeKRVmPJrmNlhgM0JlWlLUg==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, + "node_modules/@vibrant/color": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@vibrant/color/-/color-4.0.4.tgz", + "integrity": "sha512-Fq2tAszz4QOPWfHZ+KuEAchXUD8i594BM2fOJt8dI/fvYbiVoBycBF/BlNH6F4IWBubxXoPqD4JmmAHvFYbNew==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + } + }, + "node_modules/@vibrant/core": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@vibrant/core/-/core-4.0.4.tgz", + "integrity": "sha512-yZ0XSpW2biKyaJPpBC31AVYgn7NseKSO2q3KNMmDrkL2qC6TEWsBMnSQ28n0m///chZELXpQLx1CCOsWg5pj8w==", + "license": "MIT", + "dependencies": { + "@vibrant/color": "^4.0.4", + "@vibrant/generator": "^4.0.4", + "@vibrant/image": "^4.0.4", + "@vibrant/quantizer": "^4.0.4", + "@vibrant/worker": "^4.0.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + } + }, + "node_modules/@vibrant/generator": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@vibrant/generator/-/generator-4.0.4.tgz", + "integrity": "sha512-rwq8PnlpKdch4YqaA1FAwdm71gKE2cMrUsbu72TqRFGa8rpP1roaZlQCVXIIwElXVc3r9axZyAcqyTLaMjhrTg==", + "license": "MIT", + "dependencies": { + "@vibrant/color": "^4.0.4", + "@vibrant/types": "^4.0.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + } + }, + "node_modules/@vibrant/generator-default": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@vibrant/generator-default/-/generator-default-4.0.4.tgz", + "integrity": "sha512-QeVDeH2dz9lityvJCb84Ml4hlBTElwCpU7SVpiDFBh6gPoCLnzcb1H9G4NgG3hOlAPyrBM+Ivq1Pg+1lZj5Ywg==", + "license": "MIT", + "dependencies": { + "@vibrant/color": "^4.0.4", + "@vibrant/generator": "^4.0.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + } + }, + "node_modules/@vibrant/image": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@vibrant/image/-/image-4.0.4.tgz", + "integrity": "sha512-NBIJj7umfDRVpFjJHQo1AFSCWCzQyjfil+Yxu7W62PEL72GPCif0CDiglPkvVF8QhDLmnx/x1k3LIBb9jWF2sw==", + "license": "MIT", + "dependencies": { + "@vibrant/color": "^4.0.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + } + }, + "node_modules/@vibrant/image-browser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@vibrant/image-browser/-/image-browser-4.0.4.tgz", + "integrity": "sha512-7qVyAm+z9t98iwMDzUgGCwgRg0KBB5RXQFgiO2Um5Izd1wO7BKC0SHVEz2k7sRx3XNfBf+JExp8quPrvSz17gg==", + "license": "MIT", + "dependencies": { + "@vibrant/image": "^4.0.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + } + }, + "node_modules/@vibrant/image-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@vibrant/image-node/-/image-node-4.0.4.tgz", + "integrity": "sha512-aG8Ukt9oTa6FWaAV5oBKsBetkKASWH31hZiFJ2R1291f3TZlphUyQTJz5TubucIRsCEl4dgG1xyxFPgse2IABA==", + "license": "MIT", + "dependencies": { + "@jimp/custom": "^0.22.12", + "@jimp/plugin-resize": "^0.22.12", + "@jimp/types": "^0.22.12", + "@vibrant/image": "^4.0.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + } + }, + "node_modules/@vibrant/quantizer": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@vibrant/quantizer/-/quantizer-4.0.4.tgz", + "integrity": "sha512-722CooC2W4mlBiv+zyAsIrIvARnMCN/P2Muo8bnWd0SQlVWFtQnFxJWGOUPOPS4DGe3pGoqmNfvS0let4dICZQ==", + "license": "MIT", + "dependencies": { + "@vibrant/color": "^4.0.4", + "@vibrant/image": "^4.0.4", + "@vibrant/types": "^4.0.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + } + }, + "node_modules/@vibrant/quantizer-mmcq": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@vibrant/quantizer-mmcq/-/quantizer-mmcq-4.0.4.tgz", + "integrity": "sha512-/1CNnM96J8K+OBCWNUzywo6VdnmdFJyiKO+ty/nkfe8H0NseOEHIL7PrVtWGgtsb0rh2uTAq2rjXv65TfgPy8g==", + "license": "MIT", + "dependencies": { + "@vibrant/color": "^4.0.4", + "@vibrant/image": "^4.0.4", + "@vibrant/quantizer": "^4.0.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + } + }, + "node_modules/@vibrant/types": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@vibrant/types/-/types-4.0.4.tgz", + "integrity": "sha512-Qq3mVTJamn7yD4OBgBEUKaxfDlm3sxBK55N7dH3XzI9Ey7KR00R06uwtqOcEJMsziWTEXdYN3VUlYaj2Tkt7hw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + } + }, + "node_modules/@vibrant/worker": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@vibrant/worker/-/worker-4.0.4.tgz", + "integrity": "sha512-Q/R6PYhSMWCXEk/IcXbWIzIu7Z4b58ABkGvcdF8Y+q/7g+KnpxKW5x/jfQ/6ciyYSby13wZZoEdNr3QQVgsdBQ==", + "license": "MIT", + "dependencies": { + "@vibrant/types": "^4.0.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz", + "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.2", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agent-cli-detector": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.7.tgz", + "integrity": "sha512-d8OWDVdZMgjhLUT9ZPgSv/BdFFF9pVuscC0JdUSz3bjwE15gcp6u/o0/JooM2yyAWC49KThFhXlgTXRa9B7yng==", + "license": "MIT", + "bin": { + "agent-cli-detector": "dist/cli.js" + }, + "engines": { + "node": ">=18.18" + } + }, + "node_modules/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/babel-plugin-react-native-web": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.3.tgz", + "integrity": "sha512-e4fG8NbyRvk5GwU/7G2U9WxzPYtpC4IQN28AyRDzKE0kvywgohq8vT2x0/xBfg8a0RfSkXLlHAtUAGlSy3VhAw==", + "license": "MIT" + }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.0.tgz", + "integrity": "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.36.0" + } + }, + "node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-estree": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", + "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", + "license": "MIT" + }, + "node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", + "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.36.0" + } + }, + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.26", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", + "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001813", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001813.tgz", + "integrity": "sha512-zfjJo4rM0+fUomGDBW/xcDjhIwz/210DGvip2MAMDZ8KHcRPnOHmEgHPZP0UHlZoxr8fYKVJOqcORhYQcG4FKQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chromium-edge-launcher": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz", + "integrity": "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4" + } + }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dnssd-advertise": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.6.tgz", + "integrity": "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==", + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.440", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.440.tgz", + "integrity": "sha512-SghDzqdJokdz8zP8YNlvS74+CwLRoUPDGvP/gFA+HrKVw+pOshUAgx8pXR37xl/u16zGae746rFNWvZ/22kU2Q==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/expo": { + "version": "57.0.26", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.26.tgz", + "integrity": "sha512-U1rsFwcuZNnU61Z/YLnnVC/ZqP0wpxh/rCKUMff3ot/rhLpxFY1zxEqu4vyaexyvBAB1mwpl9lA/ltcHBoQGyg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@expo/cli": "^57.0.27", + "@expo/config": "~57.0.9", + "@expo/config-plugins": "~57.0.9", + "@expo/devtools": "~57.0.1", + "@expo/dom-webview": "~57.0.1", + "@expo/fingerprint": "^0.20.13", + "@expo/local-build-cache-provider": "^57.0.8", + "@expo/log-box": "^57.0.4", + "@expo/metro": "~56.0.2", + "@expo/metro-config": "~57.0.12", + "@ungap/structured-clone": "^1.3.0", + "babel-preset-expo": "~57.0.13", + "expo-asset": "~57.0.18", + "expo-constants": "~57.0.20", + "expo-file-system": "~57.0.7", + "expo-font": "~57.0.4", + "expo-keep-awake": "~57.0.2", + "expo-modules-autolinking": "~57.0.13", + "expo-modules-core": "~57.0.20", + "pretty-format": "^29.7.0", + "react-refresh": "^0.14.2", + "whatwg-url-minimum": "^0.1.2" + }, + "bin": { + "expo": "bin/cli", + "expo-modules-autolinking": "bin/autolinking", + "fingerprint": "bin/fingerprint" + }, + "peerDependencies": { + "@expo/dom-webview": "*", + "@expo/metro-runtime": "*", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-web": "*", + "react-native-webview": "*" + }, + "peerDependenciesMeta": { + "@expo/dom-webview": { + "optional": true + }, + "@expo/metro-runtime": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-web": { + "optional": true + }, + "react-native-webview": { + "optional": true + } + } + }, + "node_modules/expo-build-properties": { + "version": "57.0.22", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-57.0.22.tgz", + "integrity": "sha512-A5oRf2I02XDRu2NnqKo7Au5kUu244unvyoRMUsB+xvg/BOlsR+G/oyDFGkjbda1cJbeKm9eqposIXbA2ri5UrQ==", + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^57.0.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-constants": { + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.20.tgz", + "integrity": "sha512-WycPVvBgbluSa6yAh6DKnX646ZHrae0gV2ZLum5fioNkR+DiWyulq1+UfS++Jc6yfAh/l55NOirv6mt5cnQhSw==", + "license": "MIT", + "dependencies": { + "@expo/env": "~2.4.3" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-dev-client": { + "version": "57.0.19", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-57.0.19.tgz", + "integrity": "sha512-7giMK6BLL685GECHem2UAQvS473ImZLi5l+Q5tq+XSyv6I9UNVPs0p07mKwgBzq8z7DR3d4nTl702ziFxTjO3Q==", + "license": "MIT", + "dependencies": { + "expo-dev-launcher": "~57.0.20", + "expo-dev-menu": "~57.0.18", + "expo-dev-menu-interface": "~57.0.0", + "expo-manifests": "~57.0.2", + "expo-updates-interface": "~57.0.2" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-launcher": { + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-57.0.20.tgz", + "integrity": "sha512-F6oAHaDn9j3L/gyMGE+OalriX6PHZWnZLxpN5jc6crTrp8f6hSwGhmsHpAvdOzijfMjyi3R2UkcISFsCIIQYFA==", + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^57.0.2", + "expo-dev-menu": "~57.0.18", + "expo-manifests": "~57.0.2" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-dev-menu": { + "version": "57.0.18", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-57.0.18.tgz", + "integrity": "sha512-H7m9ENtVYz4FpJktQj+z/njIN1t7kOEaKCv8Qgv2SdAlyFTW8s2EnufxPoSlg2zUTanaauJ6wrR6XESHwW7kpw==", + "license": "MIT", + "dependencies": { + "expo-dev-menu-interface": "~57.0.0" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-dev-menu-interface": { + "version": "57.0.0", + "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-57.0.0.tgz", + "integrity": "sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-file-system": { + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", + "integrity": "sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-font": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.4.tgz", + "integrity": "sha512-7WOMC2oA6xCfOm8bbofqWn2bIqyWFm7YLEnpc/gA1MJH0Ll0TfT/3+CqRkoW2eNRWMS8sxrz5WukUmSvmNquHA==", + "license": "MIT", + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-glass-effect": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-57.0.4.tgz", + "integrity": "sha512-/AG2Kljbqfgxu+GchI2YFIbxiF9cPsIpCy4iNDzlRiZk9rvR6BwRfB6gfQVu3V2ExaoGf2h5IsEnqVI5MYNBAg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-haptics": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-57.0.3.tgz", + "integrity": "sha512-qucUXtwtJdtECKSQK5D6TQBc/0SxKj7s5wQ4mrfSxhZsHrS9a4rcxINl/gbruMaY9spTXStco32ST4pV0vXmxw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image": { + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.5.tgz", + "integrity": "sha512-jOm5lofaIKXhQ7i0Hz3I5JuNULw/aghmpIPZXddwQcEtw8+wUhlozq3M7LbsOrcsBEapcG4jFs85DsfuFoz1tQ==", + "license": "MIT", + "dependencies": { + "sf-symbols-typescript": "^2.2.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-image-loader": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.1.tgz", + "integrity": "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-manipulator": { + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-57.0.20.tgz", + "integrity": "sha512-LyuoLOJVNWNVEOABqcNCHrAiTtaRQm3usJMK4veG1hnglmt85eSuQTgUaug2TdUyAwGDJbdee4Iif+toTQwNgA==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.20.tgz", + "integrity": "sha512-plgcGuMZMWqDchjXQW6S/WepYLB5KqfbZOzwC6xe0+j5IhY4ipvVuRZcXeP/0NgKgU0Jiu/XoTK4Bi8crtZ+sQ==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-json-utils": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.2.tgz", + "integrity": "sha512-9kYZECLKE3pYZQs+kt3+ggY83Lfryog9/UEo3HJKGcHUZeKKtlFBIPFjI4LzREubsvjP7AsvG0FdEX9EquIoAQ==", + "license": "MIT" + }, + "node_modules/expo-linear-gradient": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-57.0.2.tgz", + "integrity": "sha512-eGNSfcbdXYvw0B/TQ3nmXKQgj0jLUi3EgsXGlvI79rlXg5RR5dQTkY2yqkf7BQDM3PMd3tqBMZdsLhvBqSUaMg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-linking": { + "version": "57.0.11", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.11.tgz", + "integrity": "sha512-bfTyiPb8szacs8XkGowXt5ssH/vbF26vFG8WUWQar8CcRpQcIzXY980ZkP7pK7Pcdy5MattwFdujrWOxwv5s3Q==", + "license": "MIT", + "dependencies": { + "expo-constants": "~57.0.19", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-manifests": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-57.0.2.tgz", + "integrity": "sha512-pPIIcKR73OShwlWMg8H90WRzKUd2krDdKlKAwuQV1FtUwE7H3hQEFw0ePFQY9yFoGMF/77ryYwNKfI5umMcCog==", + "license": "MIT", + "dependencies": { + "expo-json-utils": "~57.0.2" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-modules-autolinking": { + "version": "57.0.13", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.13.tgz", + "integrity": "sha512-Hj5NjZRlfccazhKab+wK19leRVUr1Y/PUERaRvegIbUSkq4GVxKq6lU9QHlKQyC16MdD/VS4iwsiLivB4o7lvQ==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^57.0.5", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.1.0", + "commander": "^7.2.0" + }, + "bin": { + "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + } + }, + "node_modules/expo-modules-core": { + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.20.tgz", + "integrity": "sha512-vEL3aeih32yuD7sQHB3Y3LKXRT8mMgoVQWFIcHtX811Bl0StnNZUG2vT5je4fe+OWYVfkjb6gNqfMc04moREgA==", + "license": "MIT", + "dependencies": { + "@expo/expo-modules-macros-plugin": "0.6.1", + "expo-modules-jsi": "~57.1.1", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-worklets": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0" + }, + "peerDependenciesMeta": { + "react-native-worklets": { + "optional": true + } + } + }, + "node_modules/expo-modules-jsi": { + "version": "57.1.1", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.1.1.tgz", + "integrity": "sha512-ssnwz3ZuIgCbZj5A3J6NlhQrXZojg+BjkeWrOWqVJhyMHlV/A/cRT7s986chKku97BFt6ucmDu6/BCzrV45Wcw==", + "license": "MIT", + "peerDependencies": { + "react-native": "*" + } + }, + "node_modules/expo-router": { + "version": "57.0.24", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.24.tgz", + "integrity": "sha512-XpqE4Oh4yrmc+sVfsDQPLErBlus//85ulLRghVZltYcdvFm9yNzErmaKrXuBVMUL6D3tnpDtkIgxESG9hYBd7w==", + "license": "MIT", + "dependencies": { + "@expo/log-box": "^57.0.4", + "@expo/metro-runtime": "^57.0.16", + "@expo/schema-utils": "^57.0.2", + "@expo/ui": "^57.0.21", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-tabs": "^1.1.12", + "@react-native-masked-view/masked-view": "^0.3.2", + "client-only": "^0.0.1", + "color": "^4.2.3", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "expo-glass-effect": "^57.0.4", + "expo-server": "^57.0.3", + "expo-symbols": "^57.0.3", + "fast-deep-equal": "^3.1.3", + "invariant": "^2.2.4", + "nanoid": "^3.3.18", + "query-string": "^7.1.3", + "react-fast-compare": "^3.2.2", + "react-is": "^19.1.0", + "react-native-drawer-layout": "^4.2.2", + "react-native-screens": "^4.26.0", + "server-only": "^0.0.1", + "sf-symbols-typescript": "^2.1.0", + "shallowequal": "^1.1.0", + "standard-navigation": "^0.0.5", + "vaul": "^1.1.2" + }, + "peerDependencies": { + "@expo/log-box": "^57.0.4", + "@expo/metro-runtime": "^57.0.16", + "@testing-library/react-native": ">= 13.2.0", + "expo": "*", + "expo-constants": "^57.0.20", + "expo-linking": "^57.0.11", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-gesture-handler": "*", + "react-native-reanimated": "*", + "react-native-safe-area-context": ">= 5.4.0", + "react-native-screens": "^4.26.0", + "react-native-web": "*", + "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" + }, + "peerDependenciesMeta": { + "@testing-library/react-native": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-gesture-handler": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + }, + "react-native-web": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-tabs": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT" + }, + "node_modules/expo-secure-store": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.4.tgz", + "integrity": "sha512-7zBA1RThURTXdP4w65kBzZNbcSsfArqVd+p+ETAnPRWXO8J296Qd0YaCVyRpJ0HSwlCnGdvOnqO+NgSreHsOhA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-server": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz", + "integrity": "sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ==", + "license": "MIT", + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/expo-status-bar": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.1.tgz", + "integrity": "sha512-Xwaq1gAoVRWx5dPG5VhT5RSbnI9OilhZnO5qoPBnUaBAa5VzRzfdS8q0/bsPt0jR2DKLtGuP0bQ6efMJ4RIMDg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-symbols": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.3.tgz", + "integrity": "sha512-uuK5yHLVR7kExUCrO/ItBvp/kp1maQ9Nkb6KvNy5ZUQnN0NMKwuhJ4GvbfS9N3ACmvCjTT4gKvg4yH5UuKIYNA==", + "license": "MIT", + "dependencies": { + "@expo-google-fonts/material-symbols": "^0.4.1", + "sf-symbols-typescript": "^2.0.0" + }, + "peerDependencies": { + "expo": "*", + "expo-font": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-system-ui": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-57.0.4.tgz", + "integrity": "sha512-z//rxQJzhTNA1PcKrUtWmrd21jAS3nEDu1b0n8B/7LTTR933OcsZYP1HnbaZifuo2ZH/h7nce2Ow61WZ1Ig2bg==", + "license": "MIT", + "dependencies": { + "@react-native/normalize-colors": "0.86.3", + "debug": "^4.3.2" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-updates-interface": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-57.0.2.tgz", + "integrity": "sha512-x3SA5toiHZEkW+5oeNdUM1Rvt6M3ExegQsSwS3Z1+S9gn9utGYTGBkb8MBQivhjIZiRlQYDIhauxA8kySQEWtQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo/node_modules/@expo/cli": { + "version": "57.0.27", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.27.tgz", + "integrity": "sha512-Jauk4chxmpVG5ElrMLTCwjFP20jbYU7PB4l3LYV8j+4e/3HU5jdl7qTQ4eVy1AaDUE9Ia2NCQ5A9kar2V1gaLg==", + "license": "MIT", + "dependencies": { + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~57.0.9", + "@expo/config-plugins": "~57.0.9", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.4.3", + "@expo/image-utils": "^0.11.5", + "@expo/inline-modules": "^0.1.7", + "@expo/json-file": "^11.0.1", + "@expo/log-box": "^57.0.4", + "@expo/metro": "~56.0.2", + "@expo/metro-config": "~57.0.12", + "@expo/metro-file-map": "^57.0.3", + "@expo/osascript": "^2.7.1", + "@expo/package-manager": "^1.13.1", + "@expo/plist": "^0.8.1", + "@expo/prebuild-config": "^57.0.16", + "@expo/require-utils": "^57.0.5", + "@expo/router-server": "^57.0.11", + "@expo/schema-utils": "^57.0.2", + "@expo/spawn-async": "^1.8.0", + "@expo/ws-tunnel": "^2.0.0", + "@expo/xcpretty": "^4.4.4", + "@react-native/dev-middleware": "0.86.3", + "accepts": "^1.3.8", + "agent-cli-detector": "0.1.7", + "arg": "^5.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "dnssd-advertise": "^1.1.4", + "expo-server": "^57.0.3", + "fetch-nodeshim": "^0.4.10", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.2.1", + "multitars": "^1.0.2", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^4.0.4", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "resolve-from": "^5.0.0", + "sandbox-cli-detector": "^0.2.0", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "terminal-link": "^2.1.1", + "toqr": "^0.1.1", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1", + "zod": "^3.25.76" + }, + "bin": { + "expo-internal": "main.js" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { + "version": "57.0.11", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.11.tgz", + "integrity": "sha512-0QxbqEkceb1WDeu5VqNdnTPVHlm3U183F7Y1t5LMohmGRAkHVL0PB4tCHBvEqKpLzOzHVObw3reGkJ1tX48h8Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "@expo/metro-runtime": "^57.0.16", + "expo": "*", + "expo-constants": "^57.0.19", + "expo-font": "^57.0.4", + "expo-router": "*", + "expo-server": "^57.0.3", + "react": "*", + "react-dom": "*", + "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" + }, + "peerDependenciesMeta": { + "@expo/metro-runtime": { + "optional": true + }, + "expo-router": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/@expo/metro-config": { + "version": "57.0.12", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.12.tgz", + "integrity": "sha512-S62Lrq35HZqBFD55423pmWb8PjaiR/W02zQC1uECBmw1vTN8WZaFz4TJ0i21EeJzwfebMb9MLxL8JZ102Z6VbA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~57.0.9", + "@expo/env": "~2.4.3", + "@expo/json-file": "~11.0.1", + "@expo/metro": "~56.0.2", + "@expo/require-utils": "^57.0.5", + "@expo/spawn-async": "^1.8.0", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.5.5", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.36.0", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/@expo/ws-tunnel": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz", + "integrity": "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==", + "license": "MIT", + "peerDependencies": { + "ws": "^8.0.0" + } + }, + "node_modules/expo/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expo/node_modules/babel-preset-expo": { + "version": "57.0.13", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.13.tgz", + "integrity": "sha512-OOl3lqNQ4IagJfsNgtxuna0wpTWt1BswdyktGTOJ5EJCgdQUk/B2vaj/jsl03+kMyuIvpxKioVtbTzxh3mdvvQ==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.20.5", + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.28.6", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-plugin-codegen": "0.86.3", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.36.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "expo-widgets": "^57.0.21", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + }, + "expo-widgets": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/expo/node_modules/expo-asset": { + "version": "57.0.18", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.18.tgz", + "integrity": "sha512-zDIQkUr0NM2S7YRj98Zohf/OBuM1q0o4ctxLz1C1oe4/33lHNLjp+2v3qUtBWVwn0zKka8UGADmS8M+83Wi6pw==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.11.5", + "expo-constants": "~57.0.19" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo/node_modules/expo-keep-awake": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz", + "integrity": "sha512-GqgH746wtJImmsbyHmCQoi7cOslKuWQBbJHXr53S35Bpf5UvxCAztvnitUEd7D0QvFN2rvkOhylbZpgZZfBLlQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo/node_modules/hermes-estree": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.1.tgz", + "integrity": "sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==", + "license": "MIT" + }, + "node_modules/expo/node_modules/hermes-parser": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.1.tgz", + "integrity": "sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.36.1" + } + }, + "node_modules/expo/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expo/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expo/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expo/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/expo/node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "dotslash": "bin/dotslash" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fetch-nodeshim": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", + "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==", + "license": "MIT" + }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT" + }, + "node_modules/fontfaceobserver": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", + "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", + "license": "BSD-2-Clause" + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/gifwrap": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", + "integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "omggif": "^1.0.10" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-compiler": { + "version": "250829098.0.17", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.17.tgz", + "integrity": "sha512-qG1PXzTEtriF6oQLZF3vyHhSMxOdW5h2TqqLri0rdpstPustd2fSvRZQMVAPdlhgFwBfYnj3OUZtiO6LjYsEFw==", + "license": "MIT" + }, + "node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-q": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz", + "integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==", + "license": "MIT", + "dependencies": { + "@types/node": "16.9.1" + } + }, + "node_modules/image-q/node_modules/@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", + "license": "MIT" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jimp-compact": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz", + "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", + "license": "MIT" + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", + "license": "0BSD" + }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lan-network": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz", + "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==", + "license": "MIT", + "bin": { + "lan-network": "dist/lan-network-cli.js" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react-native": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.48.0.tgz", + "integrity": "sha512-/URnAnnF+Nshnu1OXSvSuT+boZ7pjr+zYLjQtW4GL72IxzcecIT5mC7X5i35mWBz4bHrnIDGitIR8Wt9clN9Hw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-native": "*", + "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0" + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/metro": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.5.tgz", + "integrity": "sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.35.0", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.84.5", + "metro-cache": "0.84.5", + "metro-cache-key": "0.84.5", + "metro-config": "0.84.5", + "metro-core": "0.84.5", + "metro-file-map": "0.84.5", + "metro-resolver": "0.84.5", + "metro-runtime": "0.84.5", + "metro-source-map": "0.84.5", + "metro-symbolicate": "0.84.5", + "metro-transform-plugins": "0.84.5", + "metro-transform-worker": "0.84.5", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-babel-transformer": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.5.tgz", + "integrity": "sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.84.5", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-cache": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.5.tgz", + "integrity": "sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.84.5" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-cache-key": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.5.tgz", + "integrity": "sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-config": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.5.tgz", + "integrity": "sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.84.5", + "metro-cache": "0.84.5", + "metro-core": "0.84.5", + "metro-runtime": "0.84.5", + "yaml": "^2.6.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-core": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.5.tgz", + "integrity": "sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.84.5" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-file-map": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.5.tgz", + "integrity": "sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-minify-terser": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.5.tgz", + "integrity": "sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-resolver": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.5.tgz", + "integrity": "sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-runtime": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.5.tgz", + "integrity": "sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-source-map": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.5.tgz", + "integrity": "sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.84.5", + "nullthrows": "^1.1.1", + "ob1": "0.84.5", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-symbolicate": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.5.tgz", + "integrity": "sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.84.5", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-transform-plugins": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.5.tgz", + "integrity": "sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-transform-worker": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.5.tgz", + "integrity": "sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.84.5", + "metro-babel-transformer": "0.84.5", + "metro-cache": "0.84.5", + "metro-cache-key": "0.84.5", + "metro-minify-terser": "0.84.5", + "metro-source-map": "0.84.5", + "metro-transform-plugins": "0.84.5", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multitars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.2.tgz", + "integrity": "sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-vibrant": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/node-vibrant/-/node-vibrant-4.0.4.tgz", + "integrity": "sha512-hA/pUXBE9TJ41G9FlTkzeqD5JdxgvvPGYZb/HNpdkaxxXUEnP36imSolZ644JuPun+lTd+FpWWtBpTYdp2noQA==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.15.3", + "@vibrant/core": "^4.0.4", + "@vibrant/generator-default": "^4.0.4", + "@vibrant/image-browser": "^4.0.4", + "@vibrant/image-node": "^4.0.4", + "@vibrant/quantizer-mmcq": "^4.0.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/crutchcorn" + } + }, + "node_modules/node-vibrant/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/node-vibrant/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/npm-package-arg": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", + "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "license": "ISC", + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "license": "MIT" + }, + "node_modules/ob1": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.5.tgz", + "integrity": "sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/ora/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ora/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-png": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", + "integrity": "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==", + "license": "MIT", + "dependencies": { + "pngjs": "^3.3.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pixelmatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz", + "integrity": "sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA==", + "license": "ISC", + "dependencies": { + "pngjs": "^3.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/plist/node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-freeze": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", + "integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=17.0.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-native": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "integrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "license": "MIT", + "dependencies": { + "@react-native/assets-registry": "0.86.3", + "@react-native/codegen": "0.86.3", + "@react-native/community-cli-plugin": "0.86.3", + "@react-native/gradle-plugin": "0.86.3", + "@react-native/js-polyfills": "0.86.3", + "@react-native/normalize-colors": "0.86.3", + "@react-native/virtualized-lists": "0.86.3", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-plugin-syntax-hermes-parser": "0.36.0", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "hermes-compiler": "250829098.0.17", + "invariant": "^2.2.4", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.84.3", + "metro-source-map": "^0.84.3", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.27.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "tinyglobby": "^0.2.15", + "whatwg-fetch": "^3.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@react-native/jest-preset": "0.86.3", + "@types/react": "^19.1.1", + "react": "^19.2.3" + }, + "peerDependenciesMeta": { + "@react-native/jest-preset": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-draggable-flatlist": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.3.tgz", + "integrity": "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw==", + "license": "MIT", + "dependencies": { + "@babel/preset-typescript": "^7.17.12" + }, + "peerDependencies": { + "react-native": ">=0.64.0", + "react-native-gesture-handler": ">=2.0.0", + "react-native-reanimated": ">=2.8.0" + } + }, + "node_modules/react-native-drawer-layout": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.11.tgz", + "integrity": "sha512-Aua+LAZJ6L3v7ESHsNtxN2sO+hFQKsSz3EDKOLIpWni6PdQEJGFT0Mc2UnEsvFdfV743aHfODTAaHxsJ2D0uFw==", + "license": "MIT", + "dependencies": { + "color": "^4.2.3", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*", + "react-native-gesture-handler": ">= 2.0.0", + "react-native-reanimated": ">= 2.0.0" + } + }, + "node_modules/react-native-gesture-handler": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.32.0.tgz", + "integrity": "sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA==", + "license": "MIT", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "@types/react-test-renderer": "^19.1.0", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-image-colors": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-native-image-colors/-/react-native-image-colors-2.6.0.tgz", + "integrity": "sha512-MbBPmRpp2yy8h5W7KUreByP96pey0J9habHaRSN/67O0hlR/5Izpt370BNHQVQogfHrRXfV4d8n6ZLn/2ga7Bg==", + "license": "MIT", + "dependencies": { + "node-vibrant": "^4.0.3" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-is-edge-to-edge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", + "integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-reanimated": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz", + "integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.3.1", + "semver": "^7.7.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "0.83 - 0.86", + "react-native-worklets": "0.10.x" + } + }, + "node_modules/react-native-safe-area-context": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", + "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-screens": { + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.26.2.tgz", + "integrity": "sha512-2XnWsZToKj76trGtEZzx5ELD/qOICFEprEeUntImmitQFVUkea27fiWdUSITArI356Y1qynpXZINW+Unbhky/A==", + "license": "MIT", + "dependencies": { + "react-freeze": "^1.0.0", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-svg": { + "version": "15.15.4", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz", + "integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-svg/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/react-native-svg/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, + "node_modules/react-native-svg/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-native-webview": { + "version": "13.16.1", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", + "integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-worklets": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz", + "integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/preset-typescript": "^7.28.5", + "@babel/types": "^7.27.1", + "convert-source-map": "^2.0.0", + "semver": "^7.7.4" + }, + "peerDependencies": { + "@babel/core": "*", + "@react-native/metro-config": "*", + "react": "*", + "react-native": "0.83 - 0.86" + } + }, + "node_modules/react-native/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readable-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.3.0.tgz", + "integrity": "sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/regexpu-core": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.5.2.tgz", + "integrity": "sha512-zkmVH92DlzjSFc4xKGZp/2BXIFrDIdBQBC8gUXKCsMgKYb1bkCkxfSuoW1werDjaTEMTbULkHtKFHe1L+Abnaw==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.3.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.3.tgz", + "integrity": "sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-workspace-root": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", + "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", + "license": "MIT" + }, + "node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sandbox-cli-detector": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sandbox-cli-detector/-/sandbox-cli-detector-0.2.0.tgz", + "integrity": "sha512-4lyHX0ZU0AZKwjgZ1InxZAa3PNpyEb8rOQ+Zss1ReYmhNzW0Q+h1zE5nvniXN0HaAWZaZE1zgVNEirb0R7LmNg==", + "license": "MIT", + "bin": { + "sandbox-cli-detector": "dist/cli.js" + }, + "engines": { + "node": ">=18.18" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sf-symbols-typescript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz", + "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.11.0.tgz", + "integrity": "sha512-JdxDPD0DBTyu08pq0kPC0xSNet/qsU07qT6IsX1AS8oO2ICNRY4ldNa8OAI6PuwAH8tG3lxEhbqmyp4Dw4036g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slugify": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/standard-navigation": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.5.tgz", + "integrity": "sha512-YAmzwAiiQVocZxO/VGPFiQHcu5pKiz09QIGC0MK6aRMoa3E0QkoTQgcqJr7ZZ3OMiNhu4DkaGElFI5htjOIDbw==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/structured-headers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", + "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "license": "MIT" + }, + "node_modules/timm": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/timm/-/timm-1.7.1.tgz", + "integrity": "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/toqr": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/toqr/-/toqr-0.1.1.tgz", + "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest-callback": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz", + "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/utif2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz", + "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.11" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz", + "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.2", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.2", + "@vitest/browser-preview": "5.0.2", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.2", + "@vitest/coverage-v8": "5.0.2", + "@vitest/ui": "5.0.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/warn-once": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", + "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/whatwg-url-minimum": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz", + "integrity": "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz", + "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xml2js": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", + "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 161fc35..aec5182 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,22 +1,57 @@ { "name": "truetunes-android", "version": "1.0.0", - "main": "index.ts", + "main": "expo-router/entry", "dependencies": { + "@preeternal/react-native-cookie-manager": "^7.0.0", + "@shopify/flash-list": "2.0.2", + "@tanstack/query-async-storage-persister": "^5.104.0", + "@tanstack/react-query": "^5.104.0", + "@tanstack/react-query-persist-client": "^5.104.0", "expo": "~57.0.26", + "expo-build-properties": "~57.0.22", + "expo-constants": "~57.0.20", + "expo-dev-client": "~57.0.19", + "expo-file-system": "~57.0.7", + "expo-haptics": "~57.0.3", + "expo-image": "~57.0.5", + "expo-image-manipulator": "~57.0.20", + "expo-image-picker": "~57.0.20", + "expo-linear-gradient": "~57.0.2", + "expo-linking": "~57.0.11", + "expo-router": "~57.0.24", + "expo-secure-store": "~57.0.4", "expo-status-bar": "~57.0.1", + "expo-system-ui": "~57.0.4", + "lucide-react-native": "^1.48.0", "react": "19.2.3", - "react-native": "0.86.3" + "react-native": "0.86.3", + "react-native-draggable-flatlist": "^4.0.3", + "react-native-gesture-handler": "~2.32.0", + "react-native-image-colors": "^2.6.0", + "react-native-reanimated": "4.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", + "react-native-svg": "15.15.4", + "react-native-webview": "13.16.1", + "react-native-worklets": "0.10.1" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/react": "~19.2.2", - "typescript": "~6.0.3" + "jsdom": "^30.1.1", + "react-dom": "^19.2.3", + "typescript": "~6.0.3", + "vitest": "^5.0.2" }, "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", - "web": "expo start --web" + "android": "expo run:android", + "ios": "expo run:ios", + "web": "expo start --web", + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "private": true } diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..0037b82 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -0,0 +1,43 @@ +import { Tabs } from 'expo-router'; +import { BottomTabBar } from 'expo-router/tabs'; +import { Home, ListMusic, MoreHorizontal, Search } from 'lucide-react-native'; +import { View } from 'react-native'; +import { ConnectionBanner } from '../../ui/ConnectionBanner'; +import { haptic } from '../../ui/haptics'; +import { ImagePrefetcher } from '../../ui/ImagePrefetcher'; +import { MiniPlayer } from '../../ui/MiniPlayer'; +import { useRequireDisplayName } from '../../ui/social/useRequireDisplayName'; +import { colors } from '../../ui/theme'; + +export default function TabsLayout() { + // Attribution, playlists, Recently Played and Queuedle all key off the display name. + useRequireDisplayName(); + return ( + + haptic.selection() }} + // The mini player floats above the tab bar so it is visible on every tab. + tabBar={(props) => ( + + + + + )} + screenOptions={{ + headerShown: false, + tabBarActiveTintColor: colors.text, + tabBarInactiveTintColor: colors.text3, + tabBarStyle: { backgroundColor: colors.bg, borderTopColor: colors.border }, + sceneStyle: { backgroundColor: colors.bg }, + }} + > + }} /> + }} /> + }} /> + }} /> + + + + + ); +} diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx new file mode 100644 index 0000000..ec3ef4b --- /dev/null +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -0,0 +1,209 @@ +import { useQuery } from '@tanstack/react-query'; +import { Link, router } from 'expo-router'; +import { Compass } from 'lucide-react-native'; +import { useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useAddToQueue } from '../../logic/AddToQueueContext'; +import { useDailyGame, useMyScore } from '../../logic/hooks/useDailyGame'; +import { useMyPlaylists } from '../../logic/hooks/usePlaylists'; +import { useRecentlyPlayed } from '../../logic/hooks/useRecentlyPlayed'; +import { useUsers } from '../../logic/hooks/useUsers'; +import { browseSub, getItemArt, getName } from '../../logic/lib/itemHelpers'; +import { fetchYtmSections } from '../../logic/lib/ytmSections'; +import { useRemoteContext } from '../../logic/RemoteContext'; +import type { SonosItem } from '../../logic/types/sonos'; +import { Art } from '../../ui/Art'; +import { + ArtistCircle, + CardRow, + MediaRow, + PlaylistCard, + routeForItem, + routeForPlaylist, + routeForProfile, + useItemActionSheet, +} from '../../ui/media'; +import { colors, radius, space, type } from '../../ui/theme'; + +function open(item: SonosItem) { + const href = routeForItem(item); + if (href) router.push(href as never); +} + +export default function HomeScreen() { + const insets = useSafeAreaInsets(); + const { isAuthed, displayName, groups } = useRemoteContext(); + const addToQueue = useAddToQueue(); + const { show, sheet } = useItemActionSheet(displayName); + + const { data: ytm, isLoading: ytmLoading } = useQuery({ + queryKey: ['ytm-sections'], + queryFn: fetchYtmSections, + // Like desktop's `homeEnabled`: wait for the WS bootstrap (groups) so the household is known. + enabled: isAuthed && groups.length > 0, + staleTime: 10 * 60 * 1000, + }); + const { owned, joined } = useMyPlaylists(displayName); + const { data: colleagues = [] } = useUsers(isAuthed); + + const [selectedUser, setSelectedUser] = useState(); + const { artistItems, albumItems, availableUsers, currentUserId, isLoading: recentLoading } = useRecentlyPlayed(selectedUser); + useEffect(() => { + if (currentUserId && !selectedUser) setSelectedUser(currentUserId); + }, [currentUserId, selectedUser]); + + // Desktop parity: seeing colleagues' history is unlocked by completing today's Queuedle. + const { data: todayGame } = useDailyGame(); + const todayGameId = todayGame && 'id' in todayGame ? todayGame.id : null; + const { data: myScore } = useMyScore(todayGameId, currentUserId); + const users = availableUsers.length > 0 ? availableUsers : currentUserId ? [currentUserId] : []; + const pickerLocked = users.length > 1 && !myScore?.score; + + const playlists = useMemo( + () => + [...owned, ...joined].sort((a, b) => { + if (!!b.isFavourites !== !!a.isFavourites) return b.isFavourites ? 1 : -1; + return b.updatedAt - a.updatedAt; + }), + [owned, joined], + ); + + const recentEmpty = !!selectedUser && !recentLoading && albumItems.length === 0 && artistItems.length === 0; + + return ( + + + + Home + + + + Discover + + + + + {!isAuthed ? ( + Waiting for authentication... + ) : ( + <> +

    + void addToQueue(i)} onLongPress={show} /> +
    + + {colleagues.length > 0 && ( +
    + + {colleagues.map((u) => ( + router.push(routeForProfile(u.userId) as never)}> + + + {u.userId} + + + ))} + +
    + )} + + {displayName && ( +
    + router.push('/create-playlist' as never)} + style={{ alignSelf: 'flex-start', minHeight: 44, justifyContent: 'center', marginHorizontal: space.lg, marginBottom: space.sm, paddingHorizontal: space.md, borderRadius: radius.pill, backgroundColor: colors.bg2 }} + accessibilityLabel="New playlist" + > + + New + + + {playlists.map((pl) => ( + router.push(routeForPlaylist(pl.id) as never)} /> + ))} + +
    + )} + +
    + {users.length > 1 && ( + + {users.map((u) => ( + setSelectedUser(u)} + style={[styles.chip, u === selectedUser && styles.chipActive, pickerLocked && u !== currentUserId && { opacity: 0.4 }]} + > + {u} + + ))} + + )} + {pickerLocked && Complete today's Queuedle to see your colleagues' recently played.} + {recentLoading && } + {recentEmpty && Nothing queued in the last 7 days} + {!recentLoading && artistItems.length > 0 && ( + + {artistItems.map((a, i) => ( + + ))} + + )} + {!recentLoading && + albumItems.map((item, i) => ( + open(item)} + onLongPress={() => show(item)} + onAdd={() => void addToQueue(item)} + /> + ))} +
    + +
    + void addToQueue(i)} onLongPress={show} /> +
    +
    + void addToQueue(i)} onLongPress={show} /> +
    + + )} + + {sheet} + + ); +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( + + {title} + {children} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: colors.bg }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: space.lg }, + discover: { + minHeight: 44, + flexDirection: 'row', + alignItems: 'center', + gap: space.sm, + paddingHorizontal: space.md, + borderRadius: radius.pill, + backgroundColor: colors.bg2, + }, + msg: { textAlign: 'center', marginTop: space.xl }, + sectionTitle: { paddingHorizontal: space.lg, marginBottom: space.sm }, + hrow: { paddingHorizontal: space.lg, gap: space.md }, + pad: { paddingHorizontal: space.lg }, + person: { alignItems: 'center', gap: space.xs, minWidth: 64 }, + chip: { minHeight: 36, justifyContent: 'center', paddingHorizontal: space.md, borderRadius: radius.pill, backgroundColor: colors.bg2 }, + chipActive: { backgroundColor: colors.accent }, +}); diff --git a/apps/mobile/src/app/(tabs)/more.tsx b/apps/mobile/src/app/(tabs)/more.tsx new file mode 100644 index 0000000..468ff46 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/more.tsx @@ -0,0 +1,90 @@ +import { router } from 'expo-router'; +import type { Href } from 'expo-router'; +import { ChevronRight, Gamepad2, LogOut, MessageSquare, Speaker, Trophy, User, Users } from 'lucide-react-native'; +import type { LucideIcon } from 'lucide-react-native'; +import { useEffect, useState } from 'react'; +import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useRemoteContext } from '../../logic/RemoteContext'; +import { useRequireDisplayName } from '../../ui/social/useRequireDisplayName'; +import { useShowToast } from '../../ui/Toast'; +import { colors, radius, space, type } from '../../ui/theme'; + +function Row({ Icon, label, sub, onPress, danger }: { Icon: LucideIcon; label: string; sub?: string; onPress: () => void; danger?: boolean }) { + return ( + + + + {label} + {sub ? {sub} : null} + + {!danger && } + + ); +} + +export default function MoreScreen() { + const insets = useSafeAreaInsets(); + const { displayName, setDisplayName } = useRemoteContext(); + const showToast = useShowToast(); + const [version, setVersion] = useState(null); + useRequireDisplayName(); + + useEffect(() => { + window.sonos.getVersion().then(setVersion).catch(() => setVersion(null)); + }, []); + + const openProfile = () => { + if (displayName) router.push(`/profile/${encodeURIComponent(displayName)}` as Href); + else router.push('/display-name'); + }; + + // Mirrors desktop's onSignOut: sign out of Entra, re-login, then reload the display name. + // Entra is not configured on Android yet, so both calls may be missing or reject. + const signOut = async () => { + if (typeof window.sonos.entraSignOut !== 'function') { + showToast('Sign-out is not available on Android yet', 'error'); + return; + } + try { + await window.sonos.entraSignOut(); + await window.sonos.entraReLogin(); + const name = await window.sonos.getDisplayName().catch(() => null); + setDisplayName(name); + } catch { + showToast('Sign-out is not available on Android yet', 'error'); + } + }; + + const confirmSignOut = () => + Alert.alert('Sign out', 'Sign out of your organisation account?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Sign out', style: 'destructive', onPress: () => void signOut() }, + ]); + + return ( + + More + + router.push('/leaderboard')} /> + router.push('/queuedle')} /> + + router.push('/display-name')} /> + router.push('/rooms')} /> + router.push('/feedback')} /> + + + + + True Tunes {version ? `v${version}` : ''} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: colors.bg }, + title: { paddingHorizontal: space.lg, marginBottom: space.md }, + group: { backgroundColor: colors.bg1, borderRadius: radius.lg, marginHorizontal: space.lg, marginBottom: space.lg, overflow: 'hidden' }, + row: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md + 2 }, + footer: { textAlign: 'center', marginTop: space.md }, +}); diff --git a/apps/mobile/src/app/(tabs)/queue.tsx b/apps/mobile/src/app/(tabs)/queue.tsx new file mode 100644 index 0000000..2be6468 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/queue.tsx @@ -0,0 +1,299 @@ +import { ArrowDown, ArrowUp, ListMusic, Trash2 } from 'lucide-react-native'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ActivityIndicator, Alert, BackHandler, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native'; +import type { FlatList } from 'react-native'; +import DraggableFlatList from 'react-native-draggable-flatlist'; +import type { RenderItemParams } from 'react-native-draggable-flatlist'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useAttribution } from '../../logic/hooks/useAttribution'; +import { getActiveProvider } from '../../logic/providers'; +import { useRemoteContext } from '../../logic/RemoteContext'; +import type { NormalizedQueueItem } from '../../logic/types/provider'; +import { QueueRow } from '../../ui/queue/QueueRow'; +import { + ROW_HEIGHT, buildKeys, findPlayingIndex, isPlayingRow, planSingleReorder, playingPosition, scrollOffsetForPlaying, shouldAutoFollow, removeIndices, resolveAttribution, toggleSelection, trackNumberForIndex, +} from '../../ui/queue/queueLogic'; +import { DjSection } from '../../ui/queue/DjSection'; +import { SelectionBar } from '../../ui/queue/SelectionBar'; +import { colors, space, type } from '../../ui/theme'; +import { useShowToast } from '../../ui/Toast'; +import { haptic } from '../../ui/haptics'; + +export default function QueueScreen() { + const insets = useSafeAreaInsets(); + const showToast = useShowToast(); + const { queue, playback, groups, activeGroup } = useRemoteContext(); + const { items, setItems, isLoading, error, reload } = queue; + const attribution = useAttribution(reload); + + const [selected, setSelected] = useState>(new Set()); + const [selectMode, setSelectMode] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [dragging, setDragging] = useState(false); + + // ── Keep the list on the playing track ────────────────────────────────────────────── + // Land on it when the queue first shows, follow it as tracks change while the user hasn't scrolled + // away, and offer a pill to jump back when it is off-screen. + const listRef = useRef>(null); + const scrollY = useRef(0); + const following = useRef(true); + const landedFor = useRef(null); + const lastPlayingIndex = useRef(-1); + const [viewHeight, setViewHeight] = useState(0); + const [position, setPosition] = useState<'none' | 'visible' | 'above' | 'below'>('none'); + const playingIndex = useMemo( + () => findPlayingIndex(items, playback.queueItemId, playback.currentObjectId), + [items, playback.queueItemId, playback.currentObjectId], + ); + + const refreshPosition = useCallback(() => { + const next = playingPosition(playingIndex, scrollY.current, viewHeight); + setPosition((prev) => (prev === next ? prev : next)); + }, [playingIndex, viewHeight]); + useEffect(refreshPosition, [refreshPosition]); + + const scrollToPlaying = useCallback( + (animated: boolean) => { + if (playingIndex < 0) return; + listRef.current?.scrollToOffset({ offset: scrollOffsetForPlaying(playingIndex), animated }); + }, + [playingIndex], + ); + + useEffect(() => { + if (playingIndex < 0 || viewHeight <= 0) return; + const groupKey = activeGroup?.id ?? ''; + if (landedFor.current !== groupKey) { + // First time this group's queue is shown: jump straight there, no animation. + landedFor.current = groupKey; + lastPlayingIndex.current = playingIndex; + following.current = true; + const t = setTimeout(() => scrollToPlaying(false), 60); + return () => clearTimeout(t); + } + if (shouldAutoFollow(following.current, lastPlayingIndex.current, playingIndex)) scrollToPlaying(true); + lastPlayingIndex.current = playingIndex; + }, [playingIndex, viewHeight, activeGroup?.id, scrollToPlaying]); + + const jumpToPlaying = useCallback(() => { + following.current = true; + scrollToPlaying(true); + }, [scrollToPlaying]); + + // Desktop clears the selection whenever the list changes. + useEffect(() => { setSelected(new Set()); }, [items]); + + const exitSelect = useCallback(() => { setSelectMode(false); setSelected(new Set()); }, []); + useEffect(() => { + if (!selectMode) return; + const sub = BackHandler.addEventListener('hardwareBackPress', () => { exitSelect(); return true; }); + return () => sub.remove(); + }, [selectMode, exitSelect]); + useEffect(() => { if (items.length === 0) exitSelect(); }, [items.length, exitSelect]); + + const keys = useMemo(() => buildKeys(items), [items]); + const keyOf = useCallback( + (item: NormalizedQueueItem, i?: number) => keys[items.indexOf(item)] ?? String(i), + [keys, items], + ); + + const onRefresh = useCallback(() => { + setRefreshing(true); + reload(); + setTimeout(() => setRefreshing(false), 1000); + }, [reload]); + + const onPress = useCallback((index: number) => { + if (selectMode) { + const next = toggleSelection(selected, index); + setSelected(next.selected); + if (!next.selectMode) setSelectMode(false); + return; + } + getActiveProvider().skipToTrack(trackNumberForIndex(index)).catch(() => showToast('Failed to skip to track', 'error')); + }, [selectMode, selected, showToast]); + + const onLongPress = useCallback((index: number) => { + haptic.medium(); + setSelectMode(true); + setSelected((s) => (s.has(index) ? s : new Set(s).add(index))); + }, []); + + const removeSelected = useCallback(async () => { + const indices = [...selected]; + if (indices.length === 0) return; + const sel = new Set(selected); + setItems((prev) => removeIndices(prev, sel)); + setSelected(new Set()); + try { + await window.sonos.removeFromQueue(indices); + } catch { + showToast('Failed to remove tracks', 'error'); + reload(); + } + }, [selected, setItems, reload, showToast]); + + const removeOne = useCallback(async (index: number) => { + setItems((prev) => removeIndices(prev, new Set([index]))); + try { + await window.sonos.removeFromQueue([index]); + } catch { + showToast('Failed to remove track', 'error'); + reload(); + } + }, [setItems, reload, showToast]); + + const clearQueue = useCallback(() => { + Alert.alert('Clear queue', `Clear all ${items.length} tracks?`, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear', + style: 'destructive', + onPress: async () => { + setItems([]); + exitSelect(); + try { + await window.sonos.clearQueue(); + } catch { + showToast('Failed to clear queue', 'error'); + reload(); + } + }, + }, + ]); + }, [items.length, setItems, exitSelect, reload, showToast]); + + const onDragEnd = useCallback(async ({ from, to }: { from: number; to: number }) => { + setDragging(false); + if (from === to) return; + const plan = planSingleReorder(items, from, to); + setItems(plan.next); + try { + await window.sonos.reorderQueue(plan.fromIndices, plan.toIndex, plan.queueLength); + } catch { + showToast('Failed to reorder queue', 'error'); + reload(); + } + }, [items, setItems, reload, showToast]); + + const currentObjectId = playback.currentObjectId; + + const renderItem = useCallback( + ({ item, getIndex, drag, isActive }: RenderItemParams) => { + const index = getIndex() ?? 0; + return ( + { + haptic.medium(); + drag(); + }} + /> + ); + }, + [playback.queueItemId, currentObjectId, selected, selectMode, dragging, attribution, onPress, onLongPress, removeOne], + ); + + const title = `Queue${activeGroup ? ` · ${activeGroup.name}` : ''}${items.length ? ` · ${items.length}` : ''}`; + const showSpinner = isLoading && items.length === 0; + + return ( + + + {title} + {items.length > 0 && !selectMode && ( + + + + )} + + {showSpinner ? ( + + ) : error && items.length === 0 ? ( + + {error} + Retry + + ) : ( + setViewHeight(e.nativeEvent.layout.height)}> + { + scrollY.current = y; + refreshPosition(); + }} + onScrollBeginDrag={() => { + following.current = false; + }} + getItemLayout={(_, index) => ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index })} + data={items} + keyExtractor={keyOf} + renderItem={renderItem} + onDragBegin={() => setDragging(true)} + onRelease={() => setDragging(false)} + onDragEnd={onDragEnd} + activationDistance={12} + // Rows load their own track details when they mount, so a small render window means far-away + // rows are only fetched as you scroll toward them. + initialNumToRender={12} + maxToRenderPerBatch={8} + windowSize={7} + ListFooterComponent={items.length > 0 ? : null} + extraData={[selected, selectMode, dragging, playback.queueItemId, attribution]} + refreshControl={} + ListEmptyComponent={ + + + {groups.length === 0 ? 'No speakers found.' : 'Queue is empty.'} + + } + contentContainerStyle={items.length === 0 ? styles.emptyContent : undefined} + /> + {!selectMode && (position === 'above' || position === 'below') && ( + + {position === 'above' ? : } + Now playing + + )} + + )} + {selectMode && ( + + )} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: colors.bg }, + listWrap: { flex: 1 }, + jump: { + position: 'absolute', + alignSelf: 'center', + bottom: space.lg, + flexDirection: 'row', + alignItems: 'center', + gap: space.xs, + paddingHorizontal: space.lg, + paddingVertical: space.sm, + borderRadius: 999, + backgroundColor: colors.bg1, + borderColor: colors.border2, + borderWidth: StyleSheet.hairlineWidth, + elevation: 6, + }, + header: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md }, + headerText: { flex: 1 }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: space.md, padding: space.xxl }, + emptyContent: { flexGrow: 1 }, +}); diff --git a/apps/mobile/src/app/(tabs)/search.tsx b/apps/mobile/src/app/(tabs)/search.tsx new file mode 100644 index 0000000..852fbcf --- /dev/null +++ b/apps/mobile/src/app/(tabs)/search.tsx @@ -0,0 +1,207 @@ +import { useQuery } from '@tanstack/react-query'; +import { router } from 'expo-router'; +import { Clock, Search as SearchIcon, X } from 'lucide-react-native'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useAddToQueue } from '../../logic/AddToQueueContext'; +import { browseSub, getItemArt, getName, parseServiceSearch } from '../../logic/lib/itemHelpers'; +import { api } from '../../logic/lib/sonosApi'; +import { useRemoteContext } from '../../logic/RemoteContext'; +import type { ServiceSearch } from '../../logic/types/ServiceSearch'; +import type { SonosArtist, SonosItem } from '../../logic/types/sonos'; +import { + ArtistCircle, + CardRow, + MediaRow, + routeForItem, + shapeSearchSections, + useDebouncedValue, + useItemActionSheet, +} from '../../ui/media'; +import { useRecentSearches } from '../../ui/media/useRecentSearches'; +import { colors, radius, space, type } from '../../ui/theme'; +import { isSearchable } from '../../logic/lib/searchRules'; + +function open(item: SonosItem) { + const href = routeForItem(item); + if (href) router.push(href as never); +} + +export default function SearchScreen() { + const insets = useSafeAreaInsets(); + const { displayName } = useRemoteContext(); + const addToQueue = useAddToQueue(); + const { show, sheet } = useItemActionSheet(displayName); + const [text, setText] = useState(''); + const trimmed = text.trim(); + const q = useDebouncedValue(trimmed, 350); + + const { data: results = [], isFetching } = useQuery({ + queryKey: ['search', q], + queryFn: async () => { + const r = await api.search.serviceQuery(q, { count: 50 }); + return r.error ? [] : parseServiceSearch(r.data as ServiceSearch); + }, + enabled: isSearchable(q), + staleTime: 2 * 60 * 1000, + }); + + // Recent-search rule: a query is saved when (a) the user presses the keyboard search key, or + // (b) the debounced query has finished loading with at least one result. Empty results are + // never auto-saved. Each query is auto-saved once per change (lastSaved ref). + const { recent, add: addRecent, remove: removeRecent, clear: clearRecent } = useRecentSearches(); + const lastSaved = useRef(''); + useEffect(() => { + if (!isSearchable(q) || trimmed !== q || isFetching || results.length === 0 || lastSaved.current === q) return; + lastSaved.current = q; + addRecent(q); + }, [q, trimmed, isFetching, results.length, addRecent]); + + const sections = useMemo(() => shapeSearchSections(isSearchable(q) ? results : []), [results, q]); + const settling = trimmed !== q; + const busy = isSearchable(trimmed) && (isFetching || settling); + const empty = isSearchable(q) && !busy && results.length === 0; + + return ( + + + + { + if (trimmed) { + lastSaved.current = trimmed; + addRecent(trimmed); + } + }} + selectionColor={colors.accent} + /> + {text ? ( + setText('')} hitSlop={12} accessibilityLabel="Clear search"> + + + ) : null} + + + + {busy ? : null} + {!trimmed ? Search your music services. : null} + {!trimmed && recent.length > 0 ? ( + + + Recent + + Clear + + + {recent.map((r) => ( + setText(r)} accessibilityLabel={`Search ${r}`}> + + + {r} + + removeRecent(r)} hitSlop={12} accessibilityLabel={`Remove ${r}`}> + + + + ))} + + ) : null} + {empty ? No results. : null} + + {sections.artists.length > 0 && ( +
    + + {sections.artists.map((a, i) => ( + + ))} + +
    + )} + {sections.albums.length > 0 && ( +
    + void addToQueue(i)} onLongPress={show} /> +
    + )} + {sections.playlists.length > 0 && ( +
    + void addToQueue(i)} onLongPress={show} /> +
    + )} + {sections.tracks.length > 0 && ( +
    + {sections.tracks.map((item, i) => { + const artists = ((item.artists ?? []) as SonosArtist[]).map((a) => a.name).join(', '); + return ( + void addToQueue(item)} + onSwipeAdd={() => void addToQueue(item)} + onPress={() => show(item)} + onLongPress={() => show(item)} + /> + ); + })} +
    + )} + {sections.others.length > 0 && ( +
    + {sections.others.map((item, i) => ( + void addToQueue(item)} + onPress={() => open(item)} + onLongPress={() => show(item)} + /> + ))} +
    + )} +
    + {sheet} +
    + ); +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( + + {title} + {children} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: colors.bg }, + box: { + marginHorizontal: space.lg, + minHeight: 44, + flexDirection: 'row', + alignItems: 'center', + gap: space.sm, + paddingHorizontal: space.md, + borderRadius: radius.pill, + backgroundColor: colors.bg2, + }, + input: { flex: 1, color: colors.text, fontSize: 16, paddingVertical: space.sm }, + msg: { textAlign: 'center', marginTop: space.xl }, + sectionTitle: { paddingHorizontal: space.lg, marginBottom: space.sm }, + recentHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: space.lg, marginBottom: space.xs }, + recentRow: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md }, + hrow: { paddingHorizontal: space.lg, gap: space.md }, +}); diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx new file mode 100644 index 0000000..ccfbae6 --- /dev/null +++ b/apps/mobile/src/app/_layout.tsx @@ -0,0 +1,48 @@ +import { QueryClient } from '@tanstack/react-query'; +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { AddToQueueProvider } from '../logic/AddToQueueProvider'; +import { CACHE_BUSTER, PERSIST_MAX_AGE, shouldPersistQuery } from '../logic/lib/queryPersistence'; +import { AppGate } from '../platform/AppGate'; +import { queryPersister } from '../platform/queryPersister'; +import '../platform/core'; // installs window.sonos before any hook mounts +import { ToastProvider } from '../ui/Toast'; +import { colors } from '../ui/theme'; + +// Desktop's defaults plus a long gcTime: restored queries are garbage-collected after gcTime, so it +// must be at least as long as the persisted maxAge or the cache empties itself between launches. +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, gcTime: PERSIST_MAX_AGE } } }); + +export default function RootLayout() { + return ( + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/mobile/src/app/album/[id].tsx b/apps/mobile/src/app/album/[id].tsx new file mode 100644 index 0000000..790c185 --- /dev/null +++ b/apps/mobile/src/app/album/[id].tsx @@ -0,0 +1,165 @@ +import { router, useLocalSearchParams } from 'expo-router'; +import { ListEnd, ListStart } from 'lucide-react-native'; +import { useCallback, useMemo, useState } from 'react'; +import { FlatList, StyleSheet, Text, View } from 'react-native'; +import { useAddToQueue } from '../../logic/AddToQueueContext'; +import { useAlbumBrowse } from '../../logic/hooks/useAlbumBrowse'; +import type { AlbumTrack } from '../../logic/hooks/useAlbumBrowse'; +import { useDominantColor } from '../../logic/hooks/useDominantColor'; +import { useGeniusAlbumYear } from '../../logic/hooks/useGeniusAlbumYear'; +import { usePlaylistBrowse } from '../../logic/hooks/usePlaylistBrowse'; +import { useResolveAndOpen } from '../../logic/hooks/useResolveAndOpen'; +import { splitArtists } from '../../logic/lib/itemHelpers'; +import { useRemoteContext } from '../../logic/RemoteContext'; +import type { SonosItem, SonosItemId } from '../../logic/types/sonos'; +import { Art } from '../../ui/Art'; +import { BrowseTrackRow } from '../../ui/detail/BrowseTrackRow'; +import { Centered, DetailTopBar, PillButton, tintBackground } from '../../ui/detail/DetailChrome'; +import { albumMetaLine, buildBrowseItem, parseDetailParams, totalSeconds, trackSheetItem } from '../../ui/detail/detailLogic'; +import { buildDetailRoute, makeNavigate, useItemActionSheet } from '../../ui/media'; +import { colors, space, type } from '../../ui/theme'; + +/** + * Album detail. Sonos PLAYLIST items also route here (see routes.ts), so when the album browse + * fails or comes back empty we retry as a playlist browse, like desktop's isPlaylistOrProgram path. + */ +export default function AlbumScreen() { + const p = parseDetailParams(useLocalSearchParams()); + const { displayName } = useRemoteContext(); + const addToQueue = useAddToQueue(); + const { show, sheet } = useItemActionSheet(displayName); + const [adding, setAdding] = useState(false); + const { resolveAndOpen } = useResolveAndOpen(makeNavigate((h) => router.push(h as never))); + + const album = useAlbumBrowse(p.id, p.serviceId, p.accountId, p.defaults); + const asPlaylist = album.isError || (album.isSuccess && album.data.tracks.length === 0); + const playlist = usePlaylistBrowse(asPlaylist ? p.id : undefined, p.serviceId, p.accountId, p.defaults); + + const isLoading = album.isLoading || (asPlaylist && playlist.isLoading); + const tracks: AlbumTrack[] = asPlaylist ? (playlist.data ?? []) : (album.data?.tracks ?? []); + const failed = asPlaylist && playlist.isError; + + const title = (!asPlaylist ? album.data?.title : undefined) || p.name || ''; + const artist = (!asPlaylist ? album.data?.artist : undefined) ?? ''; + const artUrl = (!asPlaylist ? album.data?.artUrl : undefined) ?? tracks[0]?.artUrl ?? null; + const artistItem = !asPlaylist ? (album.data?.artistItem ?? null) : null; + const totalTracks = !asPlaylist ? (album.data?.totalTracks ?? tracks.length) : tracks.length; + + const year = useGeniusAlbumYear(asPlaylist ? null : title, asPlaylist ? null : artist); + const tint = useDominantColor(artUrl); + + const queueItem = useMemo( + () => buildBrowseItem(asPlaylist ? 'PLAYLIST' : 'ALBUM', p, { title }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [asPlaylist, p.id, p.serviceId, p.accountId, p.defaults, title], + ); + const svc = tracks[0]?.id as SonosItemId | undefined; + const ctx = useMemo( + () => ({ serviceId: svc?.serviceId ?? p.serviceId, accountId: svc?.accountId ?? p.accountId, includeAlbum: asPlaylist }), + [svc?.serviceId, svc?.accountId, p.serviceId, p.accountId, asPlaylist], + ); + + const addAll = async (position: number) => { + if (!queueItem || adding) return; + setAdding(true); + try { + await addToQueue(queueItem, position); + } finally { + setAdding(false); + } + }; + + const openArtist = (name: string, single: boolean) => { + if (single && artistItem) { + const rid = artistItem.resource?.id as SonosItemId | undefined; + const href = buildDetailRoute('artist', { + id: rid?.objectId, + serviceId: rid?.serviceId, + accountId: rid?.accountId?.replace(/^sn_/, ''), + name, + }); + if (href) router.push(href as never); + return; + } + void resolveAndOpen(name, 'artist', { serviceId: p.serviceId, accountId: p.accountId }); + }; + + const artistParts = splitArtists(artist); + const onAdd = useCallback((item: SonosItem) => void addToQueue(item), [addToQueue]); + + const header = ( + + + + {title} + + {artistParts.length > 0 ? ( + + {artistParts.map((name, i) => ( + openArtist(name, artistParts.length === 1)}> + {i > 0 ? ', ' : ''} + {name} + + ))} + + ) : null} + {tracks.length > 0 ? {albumMetaLine(totalTracks, totalSeconds(tracks), year)} : null} + + } + disabled={adding || !tracks.length} + onPress={() => void addAll(-1)} + /> + } + disabled={adding || !tracks.length} + onPress={() => void addAll(0)} + /> + + + ); + + return ( + + + {isLoading && !tracks.length ? ( + <> + {header} + + + ) : ( + `${t.id.objectId ?? t.ordinal}-${i}`} + ListHeaderComponent={header} + ListEmptyComponent={{failed ? 'Failed to load tracks.' : 'No tracks found.'}} + contentContainerStyle={{ paddingBottom: space.xxl }} + renderItem={({ item: t, index }) => ( + show(trackSheetItem(t, ctx))} + onLongPress={() => show(trackSheetItem(t, ctx))} + /> + )} + /> + )} + {sheet} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: colors.bg }, + header: { alignItems: 'center', gap: space.sm, paddingHorizontal: space.lg, paddingTop: space.md, paddingBottom: space.lg }, + art: { borderRadius: 12 }, + center: { textAlign: 'center' }, + actions: { flexDirection: 'row', gap: space.sm, marginTop: space.sm, flexWrap: 'wrap', justifyContent: 'center' }, + msg: { textAlign: 'center', marginTop: space.xl }, +}); diff --git a/apps/mobile/src/app/artist/[id].tsx b/apps/mobile/src/app/artist/[id].tsx new file mode 100644 index 0000000..4dd86e0 --- /dev/null +++ b/apps/mobile/src/app/artist/[id].tsx @@ -0,0 +1,183 @@ +import { router, useLocalSearchParams } from 'expo-router'; +import { AtSign, ChevronDown, ChevronRight, Radio } from 'lucide-react-native'; +import { useCallback, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useAddToQueue } from '../../logic/AddToQueueContext'; +import { useArtistBrowse } from '../../logic/hooks/useArtistBrowse'; +import { useDominantColor } from '../../logic/hooks/useDominantColor'; +import { getItemArt, getName } from '../../logic/lib/itemHelpers'; +import { useRemoteContext } from '../../logic/RemoteContext'; +import type { SonosItem } from '../../logic/types/sonos'; +import { Art } from '../../ui/Art'; +import { BrowseTrackRow } from '../../ui/detail/BrowseTrackRow'; +import { Centered, DetailTopBar, tintBackground } from '../../ui/detail/DetailChrome'; +import { parseDetailParams, trackSheetItem } from '../../ui/detail/detailLogic'; +import { GeniusText } from '../../ui/detail/GeniusText'; +import { CardRow, routeForItem, useItemActionSheet } from '../../ui/media'; +import { colors, radius, space, type } from '../../ui/theme'; + +function open(item: SonosItem) { + const href = routeForItem(item); + if (href) router.push(href as never); +} + +const openUrl = (url: string) => void Linking.openURL(url).catch(() => {}); + +export default function ArtistScreen() { + const p = parseDetailParams(useLocalSearchParams()); + const { displayName, playback } = useRemoteContext(); + const addToQueue = useAddToQueue(); + const { show, sheet } = useItemActionSheet(displayName); + const { data, isLoading, isError } = useArtistBrowse(p.id, p.serviceId, p.accountId, p.defaults, p.name); + const [showAll, setShowAll] = useState(false); + + const name = data?.name || p.name || ''; + const imageUrl = data?.imageUrl ?? null; + const tint = useDominantColor(imageUrl); + const genius = data?.genius ?? null; + const altNames = genius?.alternateNames?.filter(Boolean).slice(0, 4) ?? []; + const songs = data?.topSongs ?? []; + const visibleSongs = showAll ? songs : songs.slice(0, 10); + const latest = data?.albums[0] ?? null; + const ctx = useMemo(() => ({ serviceId: p.serviceId, accountId: p.accountId, includeAlbum: true }), [p.serviceId, p.accountId]); + const onAdd = useCallback((item: SonosItem) => void addToQueue(item), [addToQueue]); + + return ( + + + + + + + {name} + + {altNames.length > 0 ? {altNames.join(' · ')} : null} + + + {isLoading ? : null} + {isError && !data ? Failed to load artist. : null} + + {songs.length > 0 && ( +
    10 ? () => setShowAll((s) => !s) : undefined} + icon={songs.length > 10 ? showAll ? : : undefined} + > + {visibleSongs.map((t, i) => ( + )?.['subtitle'] as string | undefined) || t.artists.join(', ') || undefined} + isPlaying={!!playback.trackName && t.title === playback.trackName} + onAdd={onAdd} + onPress={() => show(trackSheetItem(t, ctx))} + onLongPress={() => show(trackSheetItem(t, ctx))} + /> + ))} +
    + )} + + {latest && ( +
    + open(latest)} /> +
    + )} + + {data && data.albums.length > 1 && ( +
    + +
    + )} + + {data && data.playlists.length > 0 && ( +
    + {data.playlists.map((pl, i) => ( + open(pl)} /> + ))} +
    + )} + + {genius?.description ? ( +
    + + + +
    + ) : null} + + {genius && (genius.instagram || genius.twitter) ? ( + + {genius.instagram ? ( + openUrl(`https://instagram.com/${genius.instagram}`)} accessibilityLabel="Instagram"> + + Instagram + + ) : null} + {genius.twitter ? ( + openUrl(`https://twitter.com/${genius.twitter}`)} accessibilityLabel="X / Twitter"> + + X + + ) : null} + + ) : null} +
    + {sheet} +
    + ); +} + +function Section({ title, children, onPress, icon }: { title: string; children: ReactNode; onPress?: () => void; icon?: ReactNode }) { + return ( + + + {title} + {icon} + + {children} + + ); +} + +/** LatestReleaseCard / RadioCard: art + title + subtitle in a tappable row. */ +function ReleaseCard({ item, radio, onPress }: { item: SonosItem; radio?: boolean; onPress: () => void }) { + const sub = item['subtitle'] as string | undefined; + return ( + [styles.release, pressed && { backgroundColor: colors.bg2 }]}> + + + {radio ? ( + + + + ) : null} + + + + {getName(item)} + + {sub ? ( + + {sub} + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: colors.bg }, + header: { alignItems: 'center', gap: space.sm, paddingHorizontal: space.lg, paddingTop: space.md, paddingBottom: space.lg }, + center: { textAlign: 'center' }, + msg: { textAlign: 'center', marginTop: space.xl }, + pad: { paddingHorizontal: space.lg }, + sectionHead: { flexDirection: 'row', alignItems: 'center', gap: space.xs, paddingHorizontal: space.lg, marginBottom: space.sm, minHeight: 32 }, + release: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.xs, minHeight: 72 }, + radioOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.45)', borderRadius: radius.md }, + social: { flexDirection: 'row', gap: space.sm, marginTop: space.lg }, + socialBtn: { flexDirection: 'row', alignItems: 'center', gap: space.xs, minHeight: 44, paddingHorizontal: space.md, borderRadius: radius.pill, backgroundColor: colors.bg2 }, +}); diff --git a/apps/mobile/src/app/container/[id].tsx b/apps/mobile/src/app/container/[id].tsx new file mode 100644 index 0000000..e258917 --- /dev/null +++ b/apps/mobile/src/app/container/[id].tsx @@ -0,0 +1,87 @@ +import { router, useLocalSearchParams } from 'expo-router'; +import { useCallback } from 'react'; +import { FlatList, StyleSheet, Text, useWindowDimensions, View } from 'react-native'; +import { useAddToQueue } from '../../logic/AddToQueueContext'; +import { useContainerBrowse } from '../../logic/hooks/useContainerBrowse'; +import { browseSub, getItemArt, getName, isArtist, isProgram, isTrack } from '../../logic/lib/itemHelpers'; +import { useRemoteContext } from '../../logic/RemoteContext'; +import type { SonosItem } from '../../logic/types/sonos'; +import { Centered, DetailTopBar } from '../../ui/detail/DetailChrome'; +import { parseDetailParams } from '../../ui/detail/detailLogic'; +import { ArtistCircle, MediaCard, routeForItem, useItemActionSheet } from '../../ui/media'; +import { colors, space, type } from '../../ui/theme'; + +const GAP = space.md; + +/** Generic browse container (ITEM_AUDIO / STUDIO / PLAYLIST / CONTAINER / SECTION): a grid of children. */ +export default function ContainerScreen() { + const p = parseDetailParams(useLocalSearchParams()); + const { displayName } = useRemoteContext(); + const addToQueue = useAddToQueue(); + const { show, sheet } = useItemActionSheet(displayName); + const { data, isLoading, isError } = useContainerBrowse(p.id, p.serviceId, p.accountId, p.defaults); + const { width } = useWindowDimensions(); + const cols = width >= 600 ? 3 : 2; + const size = Math.floor((width - space.lg * 2 - GAP * (cols - 1)) / cols); + + const onAdd = useCallback((item: SonosItem) => void addToQueue(item), [addToQueue]); + + // Desktop: tracks and programs are added to the queue on tap; everything else opens. + const press = useCallback( + (child: SonosItem) => { + if (isProgram(child) || isTrack(child)) { + void addToQueue(child); + return; + } + const href = routeForItem(child); + if (href) router.push(href as never); + }, + [addToQueue], + ); + + return ( + + + {isLoading ? ( + + ) : isError ? ( + + ) : data && data.length === 0 ? ( + + ) : ( + `${(c.id as Record | undefined)?.objectId ?? getName(c)}-${i}`} + contentContainerStyle={styles.grid} + columnWrapperStyle={{ gap: GAP }} + ListHeaderComponent={p.name ? {p.name} : null} + renderItem={({ item: child }) => + isArtist(child) ? ( + + ) : ( + press(child)} + onLongPress={() => show(child)} + onAdd={() => onAdd(child)} + /> + ) + } + /> + )} + {sheet} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: colors.bg }, + grid: { paddingHorizontal: space.lg, paddingBottom: space.xxl, gap: GAP }, + title: { marginBottom: space.md }, +}); diff --git a/apps/mobile/src/app/create-playlist.tsx b/apps/mobile/src/app/create-playlist.tsx new file mode 100644 index 0000000..518b194 --- /dev/null +++ b/apps/mobile/src/app/create-playlist.tsx @@ -0,0 +1,84 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { router, Stack, useLocalSearchParams } from 'expo-router'; +import { useState } from 'react'; +import { KeyboardAvoidingView, Pressable, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useRemoteContext } from '../logic/RemoteContext'; +import { PillButton } from '../ui/detail/DetailChrome'; +import { parsePendingTrack } from '../ui/detail/detailLogic'; +import { useShowToast } from '../ui/Toast'; +import { colors, radius, space, type } from '../ui/theme'; + +/** + * New playlist (modal). Port of desktop CreatePlaylistDialog. Optional `track` query param holds a + * JSON PlaylistTrack to add to the new playlist (set by the "Create new playlist" sheet action). + */ +export default function CreatePlaylistScreen() { + const params = useLocalSearchParams<{ track?: string }>(); + const pending = parsePendingTrack(params.track); + const { displayName } = useRemoteContext(); + const queryClient = useQueryClient(); + const showToast = useShowToast(); + const insets = useSafeAreaInsets(); + const [name, setName] = useState(''); + const [isPublic, setIsPublic] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const close = () => (router.canGoBack() ? router.back() : router.replace('/')); + + async function create() { + const trimmed = name.trim(); + if (!trimmed || !displayName || busy) return; + setBusy(true); + setError(''); + try { + const playlist = await window.sonos.createPlaylist(trimmed, isPublic); + if (pending && playlist.id) await window.sonos.addTrackToPlaylist(playlist.id, { ...pending, addedBy: displayName }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + if (playlist.id) queryClient.invalidateQueries({ queryKey: ['playlist', playlist.id] }); + showToast(`Created ${trimmed}`, 'success'); + close(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setBusy(false); + } + } + + return ( + + + New playlist + {pending ? {`"${pending.trackName}" will be added.`} : null} + void create()} + placeholder="Playlist name" + placeholderTextColor={colors.text3} + autoFocus + returnKeyType="done" + maxLength={80} + style={styles.input} + selectionColor={colors.accent} + /> + setIsPublic((v) => !v)} accessibilityRole="switch" accessibilityState={{ checked: isPublic }}> + Public: anyone can join and add tracks + + + {error ? {error} : null} + + + void create()} /> + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: colors.bg1, paddingHorizontal: space.lg, gap: space.lg }, + input: { minHeight: 48, borderRadius: radius.md, backgroundColor: colors.bg2, color: colors.text, fontSize: 16, paddingHorizontal: space.md }, + switchRow: { flexDirection: 'row', alignItems: 'center', gap: space.md, minHeight: 48 }, + error: { color: colors.danger, fontSize: 13 }, + buttons: { flexDirection: 'row', gap: space.sm, justifyContent: 'flex-end' }, +}); diff --git a/apps/mobile/src/app/discover.tsx b/apps/mobile/src/app/discover.tsx new file mode 100644 index 0000000..31809d4 --- /dev/null +++ b/apps/mobile/src/app/discover.tsx @@ -0,0 +1,169 @@ +import { router } from 'expo-router'; +import { ChevronLeft, ListPlus, RefreshCw } from 'lucide-react-native'; +import { useMemo, useState } from 'react'; +import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useAddToQueue } from '../logic/AddToQueueContext'; +import { useDjDiscoveries } from '../logic/hooks/useDjDiscoveries'; +import { useDjSet } from '../logic/hooks/useDjSet'; +import { useOfficePresence } from '../logic/hooks/useOfficePresence'; +import { useUsers } from '../logic/hooks/useUsers'; +import { useRemoteContext } from '../logic/RemoteContext'; +import type { SonosItem } from '../logic/types/sonos'; +import { Art } from '../ui/Art'; +import { AddButton } from '../ui/media'; +import { SwipeRow } from '../ui/SwipeRow'; +import { colors, radius, space, type } from '../ui/theme'; + +/** DJ picks arrive as bare ids; the queue path wants a Sonos item (port of desktop toSonosItem). */ +function toSonosItem(track: DjTrack): SonosItem { + return { + title: track.trackName, + type: 'ITEM_TRACK', + subtitle: track.artist, + imageUrl: track.imageUrl, + resource: { type: 'TRACK', id: { objectId: track.uri, serviceId: track.serviceId ?? '', accountId: track.accountId ?? '' } }, + } as SonosItem; +} + +interface Pick { + uri: string; + trackName: string; + artist: string; + imageUrl?: string; + because: string; + item: SonosItem; +} + +type Mode = 'familiar' | 'discover'; + +export default function DiscoverScreen() { + const insets = useSafeAreaInsets(); + const { displayName } = useRemoteContext(); + const addToQueue = useAddToQueue(); + const { data: users } = useUsers(true, true); + const { data: presence } = useOfficePresence(); + const [selected, setSelected] = useState([]); + const [mode, setMode] = useState('discover'); + + // Stable primitive key: presence array identity changes every poll (see desktop comment). + const roomKey = (presence?.inOffice ?? []).join(','); + const opts = useMemo(() => ({ users: selected.length ? selected : roomKey ? roomKey.split(',') : [], limit: 25 }), [selected, roomKey]); + const { data, isLoading, isFetching, refetch } = useDjSet(opts); + const { discoveries, isLoading: loadingNew } = useDjDiscoveries(data?.artists, mode === 'discover'); + + const picks: Pick[] = + mode === 'discover' + ? discoveries.map((d) => ({ + uri: d.track.id.objectId ?? '', + trackName: d.track.title, + artist: d.artist, + imageUrl: d.track.artUrl ?? undefined, + because: d.because, + item: d.track.raw, + })) + : (data?.tracks ?? []).map((t) => ({ uri: t.uri, trackName: t.trackName, artist: t.artist, imageUrl: t.imageUrl, because: t.because, item: toSonosItem(t) })); + + const busy = isLoading || (mode === 'discover' && loadingNew); + const toggle = (id: string) => setSelected((p) => (p.includes(id) ? p.filter((u) => u !== id) : [...p, id])); + const inRoom = new Set(presence?.inOffice ?? []); + + const header = ( + + + Music for whoever is in the room, from what the office actually plays. Picks are scored so the least keen listener still gets a say. + + + {(['discover', 'familiar'] as Mode[]).map((m) => ( + setMode(m)} style={[styles.pill, mode === m && styles.pillActive]}> + {m === 'discover' ? 'New music' : 'Favourites'} + + ))} + refetch()} disabled={isFetching} style={styles.pill} accessibilityLabel="Refresh"> + + + picks.forEach((p) => void addToQueue(p.item, -1, { attribute: false }))} + style={[styles.pill, styles.pillActive, picks.length === 0 && { opacity: 0.4 }]} + > + + Queue all{picks.length ? ` (${picks.length})` : ''} + + + + Playing for{selected.length === 0 ? ` - ${inRoom.size ? `in the office (${inRoom.size})` : 'the whole office'}; tap names to narrow` : ''} + + + {(users ?? []).map((u) => ( + toggle(u.userId)} style={[styles.pill, selected.includes(u.userId) && styles.pillActive, selected.length === 0 && inRoom.has(u.userId) && styles.pillInferred]}> + + {u.userId} + {u.userId === displayName ? ' (you)' : ''} + + + ))} + + + ); + + return ( + + + router.back()} hitSlop={12} style={styles.back} accessibilityLabel="Back"> + + + Discover + + `${p.uri}-${i}`} + ListHeaderComponent={header} + contentContainerStyle={{ paddingBottom: insets.bottom + space.xxl }} + ListEmptyComponent={ + busy ? ( + + + {mode === 'discover' ? 'Digging for something new...' : 'Reading the room...'} + + ) : ( + + {data?.error + ? `Couldn't build a set: ${data.error}` + : mode === 'discover' + ? 'Nothing new found - the office has played everything these acts offer.' + : 'Not enough history yet - queue a few more tracks and come back.'} + + ) + } + renderItem={({ item: p }) => ( + void addToQueue(p.item, -1, { attribute: false })}> + + + + {p.trackName} + {p.artist} + {p.because} + + void addToQueue(p.item, -1, { attribute: false })} /> + + + )} + /> + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: colors.bg }, + pad: { paddingHorizontal: space.lg }, + titleRow: { flexDirection: 'row', alignItems: 'center', gap: space.sm, marginBottom: space.md }, + back: { width: 44, height: 44, marginLeft: -space.md, alignItems: 'center', justifyContent: 'center' }, + actions: { flexDirection: 'row', flexWrap: 'wrap', gap: space.sm }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: space.sm }, + pill: { minHeight: 40, flexDirection: 'row', alignItems: 'center', gap: space.xs, paddingHorizontal: space.md, borderRadius: radius.pill, backgroundColor: colors.bg2 }, + pillActive: { backgroundColor: colors.accent }, + pillInferred: { borderWidth: 1, borderColor: colors.accent }, + row: { minHeight: 64, flexDirection: 'row', alignItems: 'center', gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.xs }, + empty: { alignItems: 'center', gap: space.md, padding: space.xl, textAlign: 'center' }, +}); diff --git a/apps/mobile/src/app/display-name.tsx b/apps/mobile/src/app/display-name.tsx new file mode 100644 index 0000000..9cc304d --- /dev/null +++ b/apps/mobile/src/app/display-name.tsx @@ -0,0 +1,108 @@ +import { router, Stack } from 'expo-router'; +import { useState } from 'react'; +import { KeyboardAvoidingView, StyleSheet, Text, View } from 'react-native'; +import { useUsers } from '../logic/hooks/useUsers'; +import { useRemoteContext } from '../logic/RemoteContext'; +import { IdentityList, NewIdentityForm } from '../ui/social/identity/IdentityPicker'; +import { colors, space, type } from '../ui/theme'; + +/** + * "Who are you?" modal. Entra sign-in is unavailable on Android, so identity is a + * trust-based pick from the existing users, or a new free-text name. + */ +export default function DisplayNameScreen() { + const { displayName, setDisplayName } = useRemoteContext(); + const users = useUsers(true, true); + const [mode, setMode] = useState<'pick' | 'new'>('pick'); + const [value, setValue] = useState(''); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const save = async (name: string) => { + const trimmed = name.trim(); + if (!trimmed || saving) return; + setSaving(true); + setError(null); + try { + const result = await window.sonos.setDisplayName(trimmed); + if (result?.error === 'taken') setError('That name is already taken - try another.'); + else if (result?.error) setError('Could not save your name. Try again.'); + else { + setDisplayName(trimmed); + router.back(); + } + } catch { + setError('Could not save your name. Try again.'); + } finally { + setSaving(false); + } + }; + + return ( + + + + {mode === 'pick' ? 'Who are you?' : "What should we call you?"} + + {mode === 'pick' + ? 'Pick your name. It shows on tracks you add to the shared queue.' + : 'Your name shows on tracks you add to the shared queue.'} + + + {mode === 'pick' ? ( + <> + void users.refetch()} + current={displayName} + saving={saving} + onPick={(n) => void save(n)} + /> + {error && {error}} + + { + setError(null); + setMode('new'); + }} + accessibilityRole="button" + > + I'm someone new + + + ) : ( + <> + { setValue(t); setError(null); }} error={error} saving={saving} onSubmit={() => void save(value)} /> + { + setError(null); + setMode('pick'); + }} + > + Back to the list + + + )} + router.back()}>Not now + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, justifyContent: 'center', padding: space.lg }, + card: { + backgroundColor: colors.bg1, + borderRadius: 20, + padding: space.xl, + gap: space.md, + maxHeight: '90%', + borderColor: colors.border, + borderWidth: StyleSheet.hairlineWidth, + }, + divider: { height: StyleSheet.hairlineWidth, backgroundColor: colors.border2, marginTop: space.sm }, +}); diff --git a/apps/mobile/src/app/feedback.tsx b/apps/mobile/src/app/feedback.tsx new file mode 100644 index 0000000..3000c1d --- /dev/null +++ b/apps/mobile/src/app/feedback.tsx @@ -0,0 +1,56 @@ +import { router, Stack } from 'expo-router'; +import { useState } from 'react'; +import { KeyboardAvoidingView, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { feedbackIssueUrl } from '../logic/lib/socialFormat'; +import { useRemoteContext } from '../logic/RemoteContext'; +import { Button, ScreenHeader, Segmented, inputStyle } from '../ui/social/common'; +import { colors, space, type } from '../ui/theme'; + +/** + * Feedback. Desktop posts a GitHub issue using a bundled PAT (VITE_GITHUB_PAT) via fetch; there is + * no window.sonos backend for it and a PAT must not ship in a mobile bundle, so this opens a + * prefilled GitHub "new issue" page through window.sonos.openExternal instead. + */ +export default function FeedbackScreen() { + const { displayName } = useRemoteContext(); + const [kind, setKind] = useState<'bug' | 'feature'>('bug'); + const [title, setTitle] = useState(''); + const [desc, setDesc] = useState(''); + + const submit = () => { + void window.sonos.openExternal(feedbackIssueUrl(kind, title, desc, displayName)); + router.back(); + }; + + return ( + + + + + + + + + + + ); +} + +describe('AppContext', () => { + it('provides default isAuthed=false', () => { + render(); + expect(screen.getByTestId('authed').textContent).toBe('false'); + }); + + it('provides default activeGroupId=null', () => { + render(); + expect(screen.getByTestId('group').textContent).toBe('null'); + }); + + it('setIsAuthed updates the value', () => { + render(); + act(() => { screen.getByRole('button', { name: 'auth' }).click(); }); + expect(screen.getByTestId('authed').textContent).toBe('true'); + }); + + it('setActiveGroupId updates the value', () => { + render(); + act(() => { screen.getByRole('button', { name: 'group' }).click(); }); + expect(screen.getByTestId('group').textContent).toBe('g-1'); + }); + + it('useAppContext throws when used outside provider', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow('useAppContext must be used within AppProvider'); + spy.mockRestore(); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useAddToQueueImpl.test.tsx b/apps/mobile/src/logic/hooks/__tests__/useAddToQueueImpl.test.tsx new file mode 100644 index 0000000..e5e57ab --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useAddToQueueImpl.test.tsx @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { RemoteContext } from '../../RemoteContext'; +import type { Remote } from '../../useRemote'; +import { ToastContext } from '../../context/ToastContext'; +import { useAddToQueueImpl } from '../../useAddToQueueImpl'; +import type { SonosItem } from '../../types/sonos'; + +type Fetch = (req: { operationId: string; query?: Record; body?: unknown; headers?: Record }) => Promise; + +const track = (): SonosItem => + ({ + title: 'Loser', + type: 'ITEM_TRACK', + subtitle: 'Beck', + resource: { type: 'TRACK', id: { objectId: 't1', serviceId: '9', accountId: 'sn_42' } }, + }) as unknown as SonosItem; + +const album = (): SonosItem => + ({ + title: 'Odelay', + type: 'ITEM_ALBUM', + subtitle: 'Beck', + resource: { type: 'ALBUM', id: { objectId: 'a1', serviceId: '9', accountId: 'sn_42' } }, + }) as unknown as SonosItem; + +const playlist = (): SonosItem => + ({ + title: 'Mix', + type: 'PLAYLIST', + resource: { type: 'PLAYLIST', id: { objectId: 'p1', serviceId: '9', accountId: 'sn_42' } }, + }) as unknown as SonosItem; + +const program = (): SonosItem => + ({ + title: 'Radio', + type: 'PROGRAM', + resource: { type: 'PROGRAM', id: { objectId: 'r1', serviceId: '9', accountId: 'sn_42' } }, + }) as unknown as SonosItem; + +let setItems: ReturnType; +let reload: ReturnType; +let reloadRaw: ReturnType; +let showToast: ReturnType void>>; +let queueVersionRef: { current: string | null }; +let queueItems: unknown[]; +let fetchMock: ReturnType>; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const remote = { + activeGroupId: 'g1', + playback: { currentObjectId: null, queueItemId: null }, + queue: { items: queueItems, setItems, isLoading: false, error: null, reload, reloadRaw }, + queueIdRef: { current: 'q1' }, + queueVersionRef, + } as unknown as Remote; + return ( + + + {children} + + + ); +} + +const addCalls = () => fetchMock.mock.calls.filter((c) => c[0].operationId === 'addQueueResource').map((c) => c[0]); +const published = () => vi.mocked(window.sonos.publishQueued).mock.calls.map((c) => c[0] as Record); + +beforeEach(() => { + setItems = vi.fn(); + reload = vi.fn(); + reloadRaw = vi.fn(async () => {}); + showToast = vi.fn<(m: string, t?: "error" | "success" | "info") => void>(); + queueVersionRef = { current: 'etag-1' }; + queueItems = []; + fetchMock = vi.fn(async (req) => (req.operationId === 'addQueueResource' ? { data: {}, etag: 'etag-2' } : new Promise(() => {}))); + vi.mocked(window.sonos.fetch).mockReset().mockImplementation(fetchMock as never); + vi.mocked(window.sonos.publishQueued).mockReset().mockResolvedValue(undefined as never); + vi.mocked(window.sonos.trackEvent).mockClear(); + vi.mocked(window.sonos.loadContent).mockReset(); +}); + +describe('useAddToQueueImpl', () => { + it('adds a single track: optimistic insert, stripped account, etag, position', async () => { + const { result } = renderHook(() => useAddToQueueImpl(), { wrapper }); + await act(() => result.current.addToQueue(track())); + + expect(setItems).toHaveBeenCalledTimes(1); + const updater = setItems.mock.calls[0][0] as (p: { index: number }[]) => { index: number }[]; + expect(updater([{ index: 0 }, { index: 1 }]).map((i) => i.index)).toEqual([0, 1, 2]); + + const [req] = addCalls(); + expect(req.body).toEqual({ id: { objectId: 't1', serviceId: '9', accountId: '42' }, type: 'TRACK' }); + expect(req.query).toEqual({ position: '-1' }); + expect(req.headers).toEqual({ 'If-Match': 'etag-1' }); + expect(queueVersionRef.current).toBe('etag-2'); + expect(reload).not.toHaveBeenCalled(); + expect(window.sonos.trackEvent).toHaveBeenCalledWith('queue_add', { success: 'true', itemType: 'TRACK' }); + }); + + it('inserts at an explicit position', async () => { + const { result } = renderHook(() => useAddToQueueImpl(), { wrapper }); + await act(() => result.current.addToQueue(track(), 1)); + const updater = setItems.mock.calls[0][0] as (p: { index: number; id?: string }[]) => { index: number }[]; + const out = updater([{ index: 0, id: 'a' }, { index: 1, id: 'b' }]); + expect(out).toHaveLength(3); + expect(out.map((i) => i.index)).toEqual([0, 1, 2]); + expect(addCalls()[0].query).toEqual({ position: '1' }); + }); + + it('retries once after reloading on a stale etag', async () => { + let n = 0; + fetchMock.mockImplementation(async (req) => { + if (req.operationId !== 'addQueueResource') return new Promise(() => {}); + return n++ === 0 ? { error: 'precondition failed' } : { data: {}, etag: 'etag-3' }; + }); + reloadRaw.mockImplementation(async () => { + queueVersionRef.current = 'etag-fresh'; + }); + const { result } = renderHook(() => useAddToQueueImpl(), { wrapper }); + await act(() => result.current.addToQueue(track())); + + const calls = addCalls(); + expect(calls).toHaveLength(2); + expect(calls[1].headers).toEqual({ 'If-Match': 'etag-fresh' }); + expect(reloadRaw).toHaveBeenCalledTimes(1); + expect(reload).toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + expect(published()).toHaveLength(1); + }); + + it('toasts and skips attribution when the add fails', async () => { + fetchMock.mockImplementation(async (req) => (req.operationId === 'addQueueResource' ? { error: 'boom' } : new Promise(() => {}))); + const { result } = renderHook(() => useAddToQueueImpl(), { wrapper }); + await act(() => result.current.addToQueue(track())); + + expect(addCalls()).toHaveLength(2); + expect(showToast).toHaveBeenCalledWith('Add failed: boom', 'error'); + expect(published()).toHaveLength(0); + expect(window.sonos.trackEvent).toHaveBeenCalledWith('queue_add', { success: 'false', itemType: 'TRACK' }); + }); + + it('publishes a track attribution event for a track', async () => { + const { result } = renderHook(() => useAddToQueueImpl(), { wrapper }); + await act(() => result.current.addToQueue(track())); + expect(published()).toHaveLength(1); + expect(published()[0]).toMatchObject({ eventType: 'track', uri: 't1', trackName: 'Loser', serviceId: '9', accountId: '42' }); + }); + + it('publishes an album event for an album (and survives a failed browse)', async () => { + fetchMock.mockImplementation(async (req) => { + if (req.operationId === 'addQueueResource') return { data: {}, etag: 'e' }; + throw new Error('nope'); + }); + const { result } = renderHook(() => useAddToQueueImpl(), { wrapper }); + await act(() => result.current.addToQueue(album())); + expect(addCalls()[0].body).toMatchObject({ type: 'ALBUM' }); + expect(published()).toHaveLength(1); + expect(published()[0]).toMatchObject({ eventType: 'album', uri: 'a1', albumId: 'a1', accountId: '42', artist: 'Beck' }); + expect(setItems).not.toHaveBeenCalled(); + expect(reload).toHaveBeenCalled(); + }); + + it('fans out per-track events for a playlist', async () => { + fetchMock.mockImplementation(async (req) => { + if (req.operationId === 'addQueueResource') return { data: {}, etag: 'e' }; + if (req.operationId === 'browsePlaylist') { + return { + data: { + tracks: { + items: [ + { title: 'One', artists: [{ name: 'A' }], resource: { id: { objectId: 'x1', serviceId: '9', accountId: 'sn_7' } } }, + { title: 'Two', artists: [{ name: 'B' }], resource: { id: { objectId: 'x2', serviceId: '9', accountId: 'sn_7' } } }, + ], + }, + }, + }; + } + return new Promise(() => {}); + }); + const { result } = renderHook(() => useAddToQueueImpl(), { wrapper }); + await act(() => result.current.addToQueue(playlist())); + await waitFor(() => expect(published()).toHaveLength(2)); + expect(published().map((p) => p.uri)).toEqual(['x1', 'x2']); + expect(published()[0]).toMatchObject({ eventType: 'track', trackName: 'One', artist: 'A', accountId: '7' }); + }); + + it('attribute:false suppresses attribution', async () => { + const { result } = renderHook(() => useAddToQueueImpl(), { wrapper }); + await act(() => result.current.addToQueue(track(), -1, { attribute: false })); + await act(() => result.current.addToQueue(album(), -1, { attribute: false })); + expect(addCalls()).toHaveLength(2); + expect(published()).toHaveLength(0); + }); + + it('program items go through loadContent, not the queue API', async () => { + vi.mocked(window.sonos.loadContent).mockResolvedValueOnce({} as never); + const { result } = renderHook(() => useAddToQueueImpl(), { wrapper }); + await act(() => result.current.addToQueue(program())); + expect(window.sonos.loadContent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'PROGRAM', queueAction: 'REPLACE', id: { objectId: 'r1', accountId: 'sn_42', serviceId: '9' } }), + ); + expect(addCalls()).toHaveLength(0); + expect(reload).toHaveBeenCalled(); + }); + + it('toasts when a program fails to load', async () => { + vi.mocked(window.sonos.loadContent).mockResolvedValueOnce({ error: 'nope' } as never); + const { result } = renderHook(() => useAddToQueueImpl(), { wrapper }); + await act(() => result.current.addToQueue(program())); + expect(showToast).toHaveBeenCalledWith('Play failed: nope', 'error'); + expect(reload).not.toHaveBeenCalled(); + }); + + it('places a user add in front of the autoplay filler, but not the filler itself', async () => { + queueItems = [ + { index: 0, track: { id: 'a' } }, + { index: 1, track: { id: 'filler' } }, + ]; + vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ + enabled: true, leaseHeld: false, enqueue: null, upcoming: [], fillerUri: 'filler', + }); + const { result } = renderHook(() => useAddToQueueImpl(), { wrapper }); + await waitFor(() => expect(result.current.autoplay.fillerUri).toBe('filler')); + + await act(() => result.current.addToQueue(track())); + expect(addCalls()[0].query).toEqual({ position: '1' }); + + await act(() => result.current.addToQueue(track(), -1, { djFiller: true, attribute: false })); + expect(addCalls()[1].query).toEqual({ position: '-1' }); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useAlbumBrowse.test.ts b/apps/mobile/src/logic/hooks/__tests__/useAlbumBrowse.test.ts new file mode 100644 index 0000000..838ab74 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useAlbumBrowse.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { albumQueryOptions } from '../useAlbumBrowse'; + +const mockFetch = vi.mocked(window.sonos.fetch); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +function encodeDefaults(obj: Record) { + return btoa(JSON.stringify(obj)); +} + +function makeTrack(title: string, ordinal = 1) { + return { + title, + ordinal, + duration: '180', + images: { tile1x1: `http://art/${title}.jpg` }, + resource: { id: { objectId: `obj-${title}`, serviceId: 'gm', accountId: 'acc' }, defaults: '' }, + artists: [{ id: 'art-1', name: 'The Beatles' }], + isExplicit: false, + }; +} + +function makeAlbumResponse(overrides: Record = {}) { + return { + title: 'Abbey Road', + subtitle: 'The Beatles', + images: { tile1x1: 'http://art/album.jpg' }, + resource: { id: { objectId: 'alb-1', serviceId: 'gm', accountId: 'acc' }, defaults: undefined }, + tracks: { items: [makeTrack('Come Together', 1), makeTrack('Something', 2)], total: 2 }, + ...overrides, + }; +} + +// ─── Basic parsing ──────────────────────────────────────────────────────────── + +describe('albumQueryOptions — basic parsing', () => { + it('parses album title, artist, and artUrl from response', async () => { + mockFetch.mockResolvedValueOnce({ data: makeAlbumResponse() }); + const result = await albumQueryOptions('alb-1', 'gm', 'acc', undefined).queryFn(); + expect(result.title).toBe('Abbey Road'); + expect(result.artist).toBe('The Beatles'); + expect(result.artUrl).toBe('http://art/album.jpg'); + }); + + it('returns the correct track count', async () => { + mockFetch.mockResolvedValueOnce({ data: makeAlbumResponse() }); + const result = await albumQueryOptions('alb-1', 'gm', 'acc', undefined).queryFn(); + expect(result.tracks).toHaveLength(2); + expect(result.totalTracks).toBe(2); + }); + + it('maps track title, ordinal, and duration', async () => { + mockFetch.mockResolvedValueOnce({ data: makeAlbumResponse() }); + const result = await albumQueryOptions('alb-1', 'gm', 'acc', undefined).queryFn(); + expect(result.tracks[0].title).toBe('Come Together'); + expect(result.tracks[0].ordinal).toBe(1); + expect(result.tracks[0].durationSeconds).toBe(180); + }); + + it('maps track artists array', async () => { + mockFetch.mockResolvedValueOnce({ data: makeAlbumResponse() }); + const result = await albumQueryOptions('alb-1', 'gm', 'acc', undefined).queryFn(); + expect(result.tracks[0].artists).toEqual(['The Beatles']); + }); + + it('maps track explicit flag', async () => { + const explicit = makeTrack('Explicit Song'); + explicit.isExplicit = true; + mockFetch.mockResolvedValueOnce({ + data: makeAlbumResponse({ tracks: { items: [explicit], total: 1 } }), + }); + const result = await albumQueryOptions('alb-1', 'gm', 'acc', undefined).queryFn(); + expect(result.tracks[0].explicit).toBe(true); + }); +}); + +// ─── artistItem construction ────────────────────────────────────────────────── + +describe('artistItem construction', () => { + it('builds artistItem when defaults contain artistId', async () => { + const defaults = encodeDefaults({ artistId: 'art-beatles', artist: 'The Beatles' }); + mockFetch.mockResolvedValueOnce({ + data: makeAlbumResponse({ + resource: { id: { objectId: 'alb-1', serviceId: 'gm', accountId: 'acc' }, defaults }, + }), + }); + const result = await albumQueryOptions('alb-1', 'gm', 'acc', undefined).queryFn(); + expect(result.artistItem).not.toBeNull(); + expect(result.artistItem?.resource?.id?.objectId).toBe('art-beatles'); + }); + + it('returns null artistItem when defaults has no artistId', async () => { + mockFetch.mockResolvedValueOnce({ data: makeAlbumResponse() }); + const result = await albumQueryOptions('alb-1', 'gm', 'acc', undefined).queryFn(); + expect(result.artistItem).toBeNull(); + }); +}); + +// ─── Pagination ─────────────────────────────────────────────────────────────── + +describe('pagination', () => { + it('fetches additional pages when total > initial page size', async () => { + const page1Tracks = Array.from({ length: 50 }, (_, i) => makeTrack(`Track ${i + 1}`, i + 1)); + const page2Tracks = [makeTrack('Track 51', 51)]; + + mockFetch + .mockResolvedValueOnce({ + data: makeAlbumResponse({ tracks: { items: page1Tracks, total: 51 } }), + }) + .mockResolvedValueOnce({ + data: { tracks: { items: page2Tracks, total: 51 } }, + }); + + const result = await albumQueryOptions('alb-1', 'gm', 'acc', undefined).queryFn(); + expect(result.tracks).toHaveLength(51); + expect(result.tracks[50].title).toBe('Track 51'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('does not fetch a second page when total equals initial page count', async () => { + const tracks = Array.from({ length: 3 }, (_, i) => makeTrack(`T${i}`, i)); + mockFetch.mockResolvedValueOnce({ + data: makeAlbumResponse({ tracks: { items: tracks, total: 3 } }), + }); + + await albumQueryOptions('alb-1', 'gm', 'acc', undefined).queryFn(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('stops pagination when next page returns empty items', async () => { + const page1 = Array.from({ length: 3 }, (_, i) => makeTrack(`T${i}`, i)); + + // total claims 10 but subsequent page is empty — should stop + mockFetch + .mockResolvedValueOnce({ + data: makeAlbumResponse({ tracks: { items: page1, total: 10 } }), + }) + .mockResolvedValueOnce({ data: { tracks: { items: [], total: 10 } } }); + + const result = await albumQueryOptions('alb-1', 'gm', 'acc', undefined).queryFn(); + expect(result.tracks).toHaveLength(3); + }); + + it('stops pagination when subsequent page fetch errors', async () => { + const page1 = Array.from({ length: 3 }, (_, i) => makeTrack(`T${i}`, i)); + + mockFetch + .mockResolvedValueOnce({ + data: makeAlbumResponse({ tracks: { items: page1, total: 10 } }), + }) + .mockResolvedValueOnce({ data: null, error: 'server error' }); + + const result = await albumQueryOptions('alb-1', 'gm', 'acc', undefined).queryFn(); + expect(result.tracks).toHaveLength(3); + }); +}); + +// ─── Error handling ─────────────────────────────────────────────────────────── + +describe('error handling', () => { + it('throws when the first fetch returns an error', async () => { + mockFetch.mockResolvedValueOnce({ data: null, error: 'not found' }); + await expect(albumQueryOptions('alb-1', 'gm', 'acc', undefined).queryFn()).rejects.toThrow('not found'); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useArtistBrowse.test.ts b/apps/mobile/src/logic/hooks/__tests__/useArtistBrowse.test.ts new file mode 100644 index 0000000..caffa07 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useArtistBrowse.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { artistQueryOptions } from '../useArtistBrowse'; + +const mockFetch = vi.mocked(window.sonos.fetch); + +beforeEach(() => { + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ data: null }); +}); + +function makeArtistResponse(overrides: Record = {}) { + return { + title: 'The Beatles', + images: { tile1x1: 'https://art/beatles.jpg' }, + sections: { + items: [{ + items: [ + { type: 'ITEM_ALBUM', title: 'Abbey Road', resource: { id: { objectId: 'alb-1' } } }, + { type: 'ITEM_ALBUM', title: 'Let It Be', resource: { id: { objectId: 'alb-2' } } }, + { type: 'ITEM_PLAYLIST', title: 'The Beatles Radio', resource: { id: { objectId: 'pl-radio' } } }, + { + type: 'ITEM_PLAYLIST', + title: 'Top Songs', + resource: { id: { objectId: 'pl-top', serviceId: 'svc', accountId: 'acc' } }, + }, + ], + }], + }, + ...overrides, + }; +} + +// ─── artistQueryOptions ─────────────────────────────────────────────────────── + +describe('artistQueryOptions — basic parsing', () => { + it('parses artist name and imageUrl', async () => { + mockFetch.mockResolvedValueOnce({ data: makeArtistResponse() }); + // Top Songs playlist fetch returns empty tracks + mockFetch.mockResolvedValueOnce({ data: { tracks: { items: [] } } }); + + const result = await artistQueryOptions('art-1', 'svc', 'acc', undefined).queryFn(); + expect(result.name).toBe('The Beatles'); + expect(result.imageUrl).toBe('https://art/beatles.jpg'); + }); + + it('separates albums from playlists', async () => { + mockFetch.mockResolvedValueOnce({ data: makeArtistResponse() }); + mockFetch.mockResolvedValueOnce({ data: { tracks: { items: [] } } }); + + const result = await artistQueryOptions('art-1', 'svc', 'acc', undefined).queryFn(); + expect(result.albums).toHaveLength(2); + expect(result.playlists).toHaveLength(1); // Radio only — Top Songs excluded + }); + + it('fetches top songs when top songs playlist is found', async () => { + mockFetch.mockResolvedValueOnce({ data: makeArtistResponse() }); + mockFetch.mockResolvedValueOnce({ + data: { + tracks: { + items: [{ title: 'Come Together', duration: 'PT4M21S', images: {}, resource: { id: {} }, artists: [] }], + }, + }, + }); + + const result = await artistQueryOptions('art-1', 'svc', 'acc', undefined).queryFn(); + expect(result.topSongs).toHaveLength(1); + expect(result.topSongs[0].title).toBe('Come Together'); + }); + + it('returns empty topSongs when no top songs playlist', async () => { + const noTopSongs = makeArtistResponse({ + sections: { + items: [{ + items: [ + { type: 'ITEM_ALBUM', title: 'Abbey Road', resource: { id: { objectId: 'alb-1' } } }, + ], + }], + }, + }); + mockFetch.mockResolvedValueOnce({ data: noTopSongs }); + + const result = await artistQueryOptions('art-1', 'svc', 'acc', undefined).queryFn(); + expect(result.topSongs).toHaveLength(0); + }); + + it('throws when response has an error', async () => { + mockFetch.mockResolvedValueOnce({ data: null, error: 'Not found' }); + await expect( + artistQueryOptions('art-1', 'svc', 'acc', undefined).queryFn() + ).rejects.toThrow('Not found'); + }); + + it('returns null imageUrl when images are missing', async () => { + const noImagesNoTopSongs = makeArtistResponse({ + images: undefined, + sections: { items: [{ items: [{ type: 'ITEM_ALBUM', title: 'Abbey Road', resource: { id: { objectId: 'alb-1' } } }] }] }, + }); + mockFetch.mockResolvedValueOnce({ data: noImagesNoTopSongs }); + + const result = await artistQueryOptions('art-1', 'svc', 'acc', undefined).queryFn(); + expect(result.imageUrl).toBeNull(); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useAttribution.test.ts b/apps/mobile/src/logic/hooks/__tests__/useAttribution.test.ts new file mode 100644 index 0000000..f19905f --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useAttribution.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useAttribution, attributionContentKey } from '../useAttribution'; + +const mockOnAttributionMap = vi.mocked(window.sonos.onAttributionMap); +const mockOnAttributionEvent = vi.mocked(window.sonos.onAttributionEvent); + +beforeEach(() => { + vi.clearAllMocks(); + mockOnAttributionMap.mockReturnValue(() => {}); + mockOnAttributionEvent.mockReturnValue(() => {}); +}); + +describe('useAttribution', () => { + it('returns empty map initially', () => { + const { result } = renderHook(() => useAttribution()); + expect(result.current).toEqual({}); + }); + + it('registers attribution map listener on mount', () => { + renderHook(() => useAttribution()); + expect(mockOnAttributionMap).toHaveBeenCalledOnce(); + }); + + it('updates map when onAttributionMap fires', () => { + let mapCb: ((map: AttributionMap) => void) | undefined; + mockOnAttributionMap.mockImplementation((cb) => { mapCb = cb; return () => {}; }); + + const { result } = renderHook(() => useAttribution()); + act(() => { + mapCb?.({ 'uri:1': { user: 'alice', timestamp: 1234, trackName: 'Song', artist: 'Artist' } }); + }); + expect(result.current['uri:1'].user).toBe('alice'); + }); + + it('merges new events into existing map', () => { + let mapCb: ((map: AttributionMap) => void) | undefined; + let eventCb: ((event: AttributionEvent) => void) | undefined; + mockOnAttributionMap.mockImplementation((cb) => { mapCb = cb; return () => {}; }); + mockOnAttributionEvent.mockImplementation((cb) => { eventCb = cb; return () => {}; }); + + const { result } = renderHook(() => useAttribution()); + act(() => { + mapCb?.({ 'uri:1': { user: 'alice', timestamp: 1234, trackName: 'Song', artist: 'Artist' } }); + }); + act(() => { + eventCb?.({ type: 'queued', uri: 'uri:2', user: 'bob', timestamp: 5678, trackName: 'Other', artist: 'Band' }); + }); + + expect(result.current['uri:1'].user).toBe('alice'); + expect(result.current['uri:2'].user).toBe('bob'); + }); + + it('calls onRemoteQueue when an attribution event fires', () => { + let eventCb: ((event: AttributionEvent) => void) | undefined; + mockOnAttributionEvent.mockImplementation((cb) => { eventCb = cb; return () => {}; }); + const onRemoteQueue = vi.fn(); + + renderHook(() => useAttribution(onRemoteQueue)); + act(() => { + eventCb?.({ type: 'queued', uri: 'uri:1', user: 'charlie', timestamp: 0, trackName: 'T', artist: 'A' }); + }); + expect(onRemoteQueue).toHaveBeenCalled(); + }); + + it('indexes live events under a title+artist content key (issue #84)', () => { + let eventCb: ((event: AttributionEvent) => void) | undefined; + mockOnAttributionEvent.mockImplementation((cb) => { eventCb = cb; return () => {}; }); + + const { result } = renderHook(() => useAttribution()); + act(() => { + eventCb?.({ type: 'queued', uri: 'obj:1', user: 'dana', timestamp: 9, trackName: 'Svefn-g-englar', artist: 'Sigur Rós' }); + }); + + // A re-keyed queue row (different objectId, same title+artist) still resolves. + const ck = attributionContentKey('Svefn-g-englar', 'Sigur Rós')!; + expect(result.current[ck].user).toBe('dana'); + }); + + it('does not build a content key without both title and artist', () => { + // Title-only would mis-attribute a different same-titled song, so it's refused. + expect(attributionContentKey('Intro', '')).toBeNull(); + expect(attributionContentKey('', 'Some Artist')).toBeNull(); + }); + + it('does not index the persisted snapshot under content keys', () => { + let mapCb: ((map: AttributionMap) => void) | undefined; + mockOnAttributionMap.mockImplementation((cb) => { mapCb = cb; return () => {}; }); + + const { result } = renderHook(() => useAttribution()); + act(() => { + mapCb?.({ 'obj:1': { user: 'old', timestamp: 1, trackName: 'Foo', artist: 'Bar' } }); + }); + + // Snapshot entries must stay objectId-keyed so a track queued hours ago can't + // falsely attribute an unrelated current row with the same title+artist. + const ck = attributionContentKey('Foo', 'Bar')!; + expect(result.current['obj:1'].user).toBe('old'); + expect(result.current[ck]).toBeUndefined(); + }); + + it('calls unsubscribe functions on unmount', () => { + const unsubMap = vi.fn(); + const unsubEvent = vi.fn(); + mockOnAttributionMap.mockReturnValue(unsubMap); + mockOnAttributionEvent.mockReturnValue(unsubEvent); + + const { unmount } = renderHook(() => useAttribution()); + unmount(); + expect(unsubMap).toHaveBeenCalled(); + expect(unsubEvent).toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useAuth.test.ts b/apps/mobile/src/logic/hooks/__tests__/useAuth.test.ts new file mode 100644 index 0000000..ab9b980 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useAuth.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useAuth } from '../useAuth'; + +const mockOnAuthReady = vi.mocked(window.sonos.onAuthReady); +const mockOnAuthExpired = vi.mocked(window.sonos.onAuthExpired); + +beforeEach(() => { + vi.clearAllMocks(); + mockOnAuthReady.mockReturnValue(() => {}); + mockOnAuthExpired.mockReturnValue(() => {}); +}); + +describe('useAuth', () => { + it('returns false initially', () => { + const { result } = renderHook(() => useAuth()); + expect(result.current).toBe(false); + }); + + it('sets true when onAuthReady fires', () => { + let readyCb: (() => void) | undefined; + mockOnAuthReady.mockImplementation((cb) => { readyCb = cb; return () => {}; }); + + const { result } = renderHook(() => useAuth()); + expect(result.current).toBe(false); + + act(() => { readyCb?.(); }); + expect(result.current).toBe(true); + }); + + it('sets false when onAuthExpired fires after auth', () => { + let readyCb: (() => void) | undefined; + let expiredCb: (() => void) | undefined; + mockOnAuthReady.mockImplementation((cb) => { readyCb = cb; return () => {}; }); + mockOnAuthExpired.mockImplementation((cb) => { expiredCb = cb; return () => {}; }); + + const { result } = renderHook(() => useAuth()); + act(() => { readyCb?.(); }); + expect(result.current).toBe(true); + + act(() => { expiredCb?.(); }); + expect(result.current).toBe(false); + }); + + it('calls both unsub functions on unmount', () => { + const unsubReady = vi.fn(); + const unsubExpired = vi.fn(); + mockOnAuthReady.mockReturnValue(unsubReady); + mockOnAuthExpired.mockReturnValue(unsubExpired); + + const { unmount } = renderHook(() => useAuth()); + unmount(); + expect(unsubReady).toHaveBeenCalled(); + expect(unsubExpired).toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useAutoplay.test.ts b/apps/mobile/src/logic/hooks/__tests__/useAutoplay.test.ts new file mode 100644 index 0000000..c535413 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useAutoplay.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useAutoplay } from '../useAutoplay'; +import type { NormalizedQueueItem } from '../../types/provider'; + +const item = (id: string): NormalizedQueueItem => + ({ index: 0, track: { id, title: id, name: id, artist: 'Band' } }) as unknown as NormalizedQueueItem; + +const track: DjTrack = { + uri: 'dj-1', + trackName: 'Loser', + artist: 'Tame Impala', + score: 0.5, + because: 'the office plays this', +}; + +beforeEach(() => { + vi.mocked(window.sonos.djAutoplay).mockReset(); + vi.mocked(window.sonos.fetchDjSet).mockReset(); + localStorage.clear(); +}); + +describe('useAutoplay', () => { + it('reports the queue and now-playing to the coordinator', async () => { + vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ + enabled: true, leaseHeld: false, enqueue: null, upcoming: [track], fillerUri: null, + }); + + renderHook(() => + useAutoplay({ + groupId: 'g1', + items: [item('a'), item('b')], + nowPlayingUri: 'a', + onEnqueue: vi.fn(), + }), + ); + + await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); + expect(vi.mocked(window.sonos.djAutoplay).mock.calls[0][0]).toMatchObject({ + groupId: 'g1', + queueUris: ['a', 'b'], + nowPlayingUri: 'a', + }); + }); + + /** + * Sonos can hand a queued track back under a different objectId (issue #84). + * Without a name-based identity the coordinator loses its own filler, decides + * the add failed, and re-queues the same pick forever. + */ + it('sends name keys alongside uris, index-aligned', async () => { + vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ + enabled: true, leaseHeld: false, enqueue: null, upcoming: [], fillerUri: null, + }); + vi.mocked(window.sonos.fetchDjSet).mockResolvedValue({ + tracks: [], artists: [], listeners: [], artistsConsidered: 0, inferredListeners: true, + }); + + renderHook(() => + useAutoplay({ + groupId: 'g1', + items: [item('a'), item('b')], + nowPlayingUri: 'a', + onEnqueue: vi.fn(), + }), + ); + + await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); + const sent = vi.mocked(window.sonos.djAutoplay).mock.calls[0][0]; + expect(sent.queueKeys).toHaveLength(sent.queueUris.length); + expect(sent.queueKeys?.[0]).toBe('a||band'); + // The playhead needs the same identity to be locatable after a re-key. + expect(sent.nowPlayingKey).toBe('a||band'); + }); + + /** The whole point of the lease: four clients must not add four fillers. */ + it('only enqueues when it holds the lease', async () => { + const onEnqueue = vi.fn(); + vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ + enabled: true, leaseHeld: false, enqueue: track, upcoming: [track], fillerUri: null, + }); + + renderHook(() => + useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue }), + ); + + await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); + expect(onEnqueue).not.toHaveBeenCalled(); + }); + + it('enqueues the coordinator’s pick when it does hold the lease', async () => { + const onEnqueue = vi.fn(); + vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ + enabled: true, leaseHeld: true, enqueue: track, upcoming: [track], fillerUri: 'dj-1', + }); + + const { result } = renderHook(() => + useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue }), + ); + + await waitFor(() => expect(onEnqueue).toHaveBeenCalledWith(track)); + await waitFor(() => expect(result.current.fillerUri).toBe('dj-1')); + }); + + it('still previews before a group has been picked', async () => { + vi.mocked(window.sonos.fetchDjSet).mockResolvedValue({ + tracks: [track], artists: [], listeners: [], artistsConsidered: 1, inferredListeners: true, + }); + + const { result } = renderHook(() => + useAutoplay({ groupId: null, items: [], nowPlayingUri: null, onEnqueue: vi.fn() }), + ); + + // No group means no coordination — but the preview is still worth showing. + await waitFor(() => expect(result.current.upcoming).toHaveLength(1)); + expect(window.sonos.djAutoplay).not.toHaveBeenCalled(); + }); + + /** Each Sonos group is its own queue, with its own session and lease. */ + it('coordinates per group', async () => { + vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ + enabled: true, leaseHeld: true, enqueue: null, upcoming: [track], fillerUri: 'dj-1', + }); + + const { rerender } = renderHook( + ({ groupId }) => useAutoplay({ groupId, items: [], nowPlayingUri: null, onEnqueue: vi.fn() }), + { initialProps: { groupId: 'kitchen' as string | null } }, + ); + await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); + expect(vi.mocked(window.sonos.djAutoplay).mock.calls[0][0].groupId).toBe('kitchen'); + + rerender({ groupId: 'studio' }); + await waitFor(() => + expect( + vi.mocked(window.sonos.djAutoplay).mock.calls.some((c) => c[0].groupId === 'studio'), + ).toBe(true), + ); + }); + + it('drops the old room’s filler the moment the group changes', async () => { + vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ + enabled: true, leaseHeld: false, enqueue: null, upcoming: [track], fillerUri: 'dj-1', + }); + + const { result, rerender } = renderHook( + ({ groupId }) => useAutoplay({ groupId, items: [], nowPlayingUri: null, onEnqueue: vi.fn() }), + { initialProps: { groupId: 'kitchen' as string | null } }, + ); + await waitFor(() => expect(result.current.fillerUri).toBe('dj-1')); + + // A stale fillerUri decides where user tracks get inserted, so it must not + // survive into a queue it has nothing to do with. + vi.mocked(window.sonos.djAutoplay).mockImplementation(() => new Promise(() => {})); + rerender({ groupId: 'studio' }); + expect(result.current.fillerUri).toBeNull(); + expect(result.current.upcoming).toEqual([]); + }); + + it('reuses one client id across mounts so the lease does not bounce', async () => { + vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ + enabled: false, leaseHeld: false, enqueue: null, upcoming: [], fillerUri: null, + }); + + const opts = { groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue: vi.fn() }; + const first = renderHook(() => useAutoplay(opts)); + await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); + first.unmount(); + + renderHook(() => useAutoplay(opts)); + await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalledTimes(2)); + + const calls = vi.mocked(window.sonos.djAutoplay).mock.calls; + expect(calls[0][0].clientId).toBe(calls[1][0].clientId); + }); + + it('survives the coordinator being unreachable', async () => { + vi.mocked(window.sonos.djAutoplay).mockRejectedValue(new Error('offline')); + vi.mocked(window.sonos.fetchDjSet).mockResolvedValue({ + tracks: [], artists: [], listeners: [], artistsConsidered: 0, inferredListeners: true, + }); + const onEnqueue = vi.fn(); + + const { result } = renderHook(() => + useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue }), + ); + + await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); + expect(result.current.upcoming).toEqual([]); + // Crucially it must never enqueue on a guess — only the coordinator grants that. + expect(onEnqueue).not.toHaveBeenCalled(); + }); + + /** The preview is "what would play next" — it must not need the coordinator. */ + it('falls back to the DJ endpoint when the coordinator is unreachable', async () => { + vi.mocked(window.sonos.djAutoplay).mockRejectedValue(new Error('offline')); + vi.mocked(window.sonos.fetchDjSet).mockResolvedValue({ + tracks: [track], + artists: [], + listeners: [], + artistsConsidered: 1, + inferredListeners: true, + }); + + const { result } = renderHook(() => + useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue: vi.fn() }), + ); + + await waitFor(() => expect(result.current.upcoming).toHaveLength(1)); + expect(result.current.upcoming[0].trackName).toBe('Loser'); + }); + + it('fills the preview when the coordinator answers with nothing', async () => { + vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ + enabled: false, leaseHeld: false, enqueue: null, upcoming: [], fillerUri: null, + }); + vi.mocked(window.sonos.fetchDjSet).mockResolvedValue({ + tracks: [track], + artists: [], + listeners: [], + artistsConsidered: 1, + inferredListeners: true, + }); + + const { result } = renderHook(() => + useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue: vi.fn() }), + ); + + await waitFor(() => expect(result.current.upcoming).toHaveLength(1)); + }); + + it('never lets the stand-in overwrite what is really parked', async () => { + vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ + enabled: true, leaseHeld: false, enqueue: null, + upcoming: [{ ...track, uri: 'real', trackName: 'Actually Parked' }], + fillerUri: 'real', + }); + + const { result } = renderHook(() => + useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue: vi.fn() }), + ); + + await waitFor(() => expect(result.current.upcoming[0].trackName).toBe('Actually Parked')); + expect(window.sonos.fetchDjSet).not.toHaveBeenCalled(); + }); + + /** + * Sonos gives the playing track's queue position directly. Deriving it from + * objectIds or names is what kept failing — ids get re-keyed and the event + * log formats artists differently from the live queue. + */ + it('reports the playhead position, converted to a zero-based index', async () => { + vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ + enabled: true, leaseHeld: false, enqueue: null, upcoming: [track], fillerUri: null, + }); + + renderHook(() => + useAutoplay({ + groupId: 'g1', + items: [item('a'), item('b'), item('c')], + nowPlayingUri: 'b', + nowPlayingQueueItemId: '2', + onEnqueue: vi.fn(), + }), + ); + + await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); + expect(vi.mocked(window.sonos.djAutoplay).mock.calls[0][0].nowPlayingIndex).toBe(1); + }); + + it('sends no index when the player does not report one', async () => { + vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ + enabled: true, leaseHeld: false, enqueue: null, upcoming: [track], fillerUri: null, + }); + + renderHook(() => + useAutoplay({ + groupId: 'g1', + items: [item('a')], + nowPlayingUri: 'a', + nowPlayingQueueItemId: null, + onEnqueue: vi.fn(), + }), + ); + + await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); + expect(vi.mocked(window.sonos.djAutoplay).mock.calls[0][0].nowPlayingIndex).toBeNull(); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useContainerBrowse.test.ts b/apps/mobile/src/logic/hooks/__tests__/useContainerBrowse.test.ts new file mode 100644 index 0000000..e03711e --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useContainerBrowse.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createElement } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useContainerBrowse } from '../useContainerBrowse'; + +const mockFetch = vi.mocked(window.sonos.fetch); + +beforeEach(() => { + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ data: null }); +}); + +function wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return createElement(QueryClientProvider, { client: qc }, children); +} + +describe('useContainerBrowse', () => { + it('does not fetch when containerId is undefined', () => { + renderHook(() => useContainerBrowse(undefined, 'svc', 'acc'), { wrapper }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not fetch when serviceId is undefined', () => { + renderHook(() => useContainerBrowse('cont-1', undefined, 'acc'), { wrapper }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('fetches and returns items when all params provided', async () => { + const mockData = { + container: { + items: [{ type: 'TRACK', title: 'Song A', resource: { type: 'TRACK', id: {} } }], + }, + }; + mockFetch.mockResolvedValueOnce({ data: mockData }); + + const { result } = renderHook( + () => useContainerBrowse('cont-1', 'svc', 'acc'), + { wrapper } + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toBeInstanceOf(Array); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ operationId: 'browseContainer' }) + ); + }); + + it('returns error when response has error field', async () => { + mockFetch.mockResolvedValueOnce({ data: null, error: 'Not found' }); + const { result } = renderHook( + () => useContainerBrowse('cont-1', 'svc', 'acc'), + { wrapper } + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error).toBeTruthy(); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useDailyGame.test.ts b/apps/mobile/src/logic/hooks/__tests__/useDailyGame.test.ts new file mode 100644 index 0000000..749a823 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useDailyGame.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createElement } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { + calculateGameRankings, + dailyGameQueryOptions, + getGameRankTier, + useDailyGame, + useGameLeaderboard, + useGameRankings, + useSubmitGameScore, +} from '../useDailyGame'; + +const mockFetch = vi.mocked(window.sonos.fetchDailyGame); + +function rankingSource(scores: unknown[], maxTotal = 10) { + return { + maxTotal, + leaderboard: { scores }, + }; +} + +function gameWithQuestionCount(id: string, questionCount: number): GameDoc { + return { + id, + status: 'ready', + generatedAt: 1, + lowData: false, + questions: new Array(questionCount).fill(null) as unknown as GameQuestion[], + }; +} + +function wrapper({ children }: { children: React.ReactNode }) { + return createElement( + QueryClientProvider, + { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, + children + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockReset(); +}); + +describe('dailyGameQueryOptions', () => { + it('calls fetchDailyGame with the supplied date', async () => { + mockFetch.mockResolvedValueOnce({ status: 'pending' }); + await dailyGameQueryOptions('2026-04-21').queryFn(); + expect(mockFetch).toHaveBeenCalledWith('2026-04-21'); + }); + + it('returns the game doc when fetch succeeds', async () => { + const game: GameDoc = { + id: '2026-04-21', + status: 'ready', + generatedAt: 1, + lowData: false, + questions: [], + }; + mockFetch.mockResolvedValueOnce(game); + const result = await dailyGameQueryOptions().queryFn(); + expect(result).toBe(game); + }); + + it('returns a pending shape when the server is still generating', async () => { + mockFetch.mockResolvedValueOnce({ status: 'pending' }); + const result = await dailyGameQueryOptions().queryFn(); + expect(result).toEqual({ status: 'pending' }); + }); + + it('refetchInterval returns 30000 when status is pending', () => { + const opts = dailyGameQueryOptions(); + const interval = opts.refetchInterval({ state: { data: { status: 'pending' } as GameFetchResult } }); + expect(interval).toBe(30_000); + }); + + it('refetchInterval returns false when data is a ready game', () => { + const opts = dailyGameQueryOptions(); + const interval = opts.refetchInterval({ + state: { data: { id: 'g1', questions: [] } as unknown as GameFetchResult }, + }); + expect(interval).toBe(false); + }); + + it('uses "today" as default queryKey', () => { + const opts = dailyGameQueryOptions(); + expect(opts.queryKey).toEqual(['queuedle', 'today']); + }); +}); + +describe('useDailyGame', () => { + it('returns game data after fetch resolves', async () => { + const game: GameDoc = { id: '2026-04-21', status: 'ready', generatedAt: 1, lowData: false, questions: [] }; + mockFetch.mockResolvedValue(game); + const { result } = renderHook(() => useDailyGame(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toBe(game); + }); +}); + +describe('useGameLeaderboard', () => { + it('calls fetchGameLeaderboard with date', async () => { + vi.mocked(window.sonos.fetchGameLeaderboard).mockResolvedValue({ scores: [] } as unknown as GameLeaderboardResult); + const { result } = renderHook(() => useGameLeaderboard('2026-04-21'), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(window.sonos.fetchGameLeaderboard).toHaveBeenCalledWith('2026-04-21'); + }); +}); + +describe('calculateGameRankings', () => { + it('averages multiple games and includes one-game players', () => { + const rankings = calculateGameRankings([ + rankingSource( + [ + { userName: 'alice', mainScore: 3, bonusScore: 1, total: 4 }, + { userName: 'bob', mainScore: 2, bonusScore: 2, total: 4 }, + ], + 6 + ), + rankingSource([{ userName: 'alice', mainScore: 5, bonusScore: 1, total: 6 }], 6), + ]); + + expect(rankings[0]).toMatchObject({ + userName: 'alice', + gamesPlayed: 2, + averageTotal: 5, + averageMain: 4, + averageBonus: 1, + averagePercent: (10 / 12) * 100, + bestTotal: 6, + tierName: 'Provisional', + isProvisional: true, + }); + expect(rankings[1]).toMatchObject({ + userName: 'bob', + gamesPlayed: 1, + averageTotal: 4, + averageMain: 2, + averageBonus: 2, + averagePercent: (4 / 6) * 100, + bestTotal: 4, + tierName: 'Provisional', + isProvisional: true, + }); + }); + + it('applies tie-breakers deterministically', () => { + const rankings = calculateGameRankings([ + rankingSource([ + { userName: 'alice', mainScore: 5, bonusScore: 0, total: 5 }, + { userName: 'bob', mainScore: 4, bonusScore: 0, total: 4 }, + { userName: 'carl', mainScore: 5, bonusScore: 0, total: 5 }, + { userName: 'zoe', mainScore: 5, bonusScore: 0, total: 5 }, + { userName: 'anna', mainScore: 5, bonusScore: 0, total: 5 }, + ]), + rankingSource([ + { userName: 'alice', mainScore: 5, bonusScore: 0, total: 5 }, + { userName: 'bob', mainScore: 6, bonusScore: 0, total: 6 }, + { userName: 'carl', mainScore: 5, bonusScore: 0, total: 5 }, + { userName: 'zoe', mainScore: 5, bonusScore: 0, total: 5 }, + { userName: 'anna', mainScore: 5, bonusScore: 0, total: 5 }, + ]), + rankingSource([{ userName: 'carl', mainScore: 5, bonusScore: 0, total: 5 }]), + ]); + + expect(rankings.map((ranking) => ranking.userName)).toEqual(['carl', 'bob', 'alice', 'anna', 'zoe']); + }); + + it('skips missing and invalid leaderboard responses safely', () => { + const rankings = calculateGameRankings([ + { + maxTotal: null, + leaderboard: { scores: [{ userName: 'skipped', mainScore: 2, bonusScore: 1, total: 3 }] }, + }, + { maxTotal: 6, leaderboard: null }, + { maxTotal: 6, leaderboard: { error: 'failed' } }, + { maxTotal: 6, leaderboard: { scores: 'not scores' } }, + rankingSource( + [ + { userName: '', mainScore: 2, bonusScore: 1, total: 3 }, + { userName: 'alice', mainScore: 2, bonusScore: 1, total: 3 }, + ], + 6 + ), + ]); + + expect(rankings).toHaveLength(1); + expect(rankings[0]).toMatchObject({ userName: 'alice', gamesPlayed: 1, averageTotal: 3 }); + }); + + it('assigns percentage tiers at threshold boundaries', () => { + expect(getGameRankTier(39.9, 3).name).toBe('Skip Button Survivor'); + expect(getGameRankTier(40, 3).name).toBe('Background Bopper'); + expect(getGameRankTier(55, 3).name).toBe('Aux Cable Apprentice'); + expect(getGameRankTier(70, 3).name).toBe('Algorithm Whisperer'); + expect(getGameRankTier(85, 3).name).toBe('Playlist Prophet'); + }); + + it('marks players with fewer than three games as provisional', () => { + expect(getGameRankTier(100, 2)).toEqual({ + key: 'provisional', + name: 'Provisional', + isProvisional: true, + }); + }); +}); + +describe('useGameRankings', () => { + it('fetches ready game leaderboards and ranks the results', async () => { + vi.mocked(window.sonos.fetchGameDates).mockResolvedValue({ + dates: [ + { gameId: '2026-04-21', status: 'ready', userPlayed: true }, + { gameId: '2026-04-22', status: 'generating', userPlayed: false }, + { gameId: '2026-04-23', status: 'ready', userPlayed: false }, + ], + }); + vi.mocked(window.sonos.fetchGameLeaderboard).mockImplementation(async (date?: string) => { + if (date === '2026-04-21') { + return { + gameId: date, + scores: [{ userName: 'alice', mainScore: 3, bonusScore: 1, total: 4 }], + } as GameLeaderboardResult; + } + return { + gameId: date ?? '', + scores: [{ userName: 'alice', mainScore: 5, bonusScore: 1, total: 6 }], + } as GameLeaderboardResult; + }); + mockFetch.mockImplementation(async (date?: string) => gameWithQuestionCount(date ?? '', 3)); + + const { result } = renderHook(() => useGameRankings('alice', true), { wrapper }); + + await waitFor(() => expect(result.current.data?.[0]?.averageTotal).toBe(5)); + expect(result.current.data?.[0]?.averagePercent).toBeCloseTo((10 / 12) * 100); + expect(window.sonos.fetchGameDates).toHaveBeenCalledWith('alice'); + expect(window.sonos.fetchGameLeaderboard).toHaveBeenCalledTimes(2); + expect(window.sonos.fetchGameLeaderboard).toHaveBeenCalledWith('2026-04-21'); + expect(window.sonos.fetchGameLeaderboard).toHaveBeenCalledWith('2026-04-23'); + expect(window.sonos.fetchDailyGame).toHaveBeenCalledWith('2026-04-21'); + expect(window.sonos.fetchDailyGame).toHaveBeenCalledWith('2026-04-23'); + }); + + it('skips missing game docs while ranking', async () => { + vi.mocked(window.sonos.fetchGameDates).mockResolvedValue({ + dates: [ + { gameId: 'ready-game', status: 'ready', userPlayed: true }, + { gameId: 'missing-game', status: 'ready', userPlayed: true }, + ], + }); + vi.mocked(window.sonos.fetchGameLeaderboard).mockResolvedValue({ + gameId: 'any', + scores: [{ userName: 'alice', mainScore: 3, bonusScore: 1, total: 4 }], + } as GameLeaderboardResult); + mockFetch.mockImplementation(async (date?: string) => { + if (date === 'missing-game') return { error: 'missing' }; + return gameWithQuestionCount(date ?? '', 2); + }); + + const { result } = renderHook(() => useGameRankings('alice', true), { wrapper }); + + await waitFor(() => expect(result.current.data?.[0]?.gamesPlayed).toBe(1)); + expect(result.current.data?.[0]?.averagePercent).toBe(100); + }); + + it('does not fetch while disabled', () => { + renderHook(() => useGameRankings('alice', false), { wrapper }); + expect(window.sonos.fetchGameDates).not.toHaveBeenCalled(); + expect(window.sonos.fetchGameLeaderboard).not.toHaveBeenCalled(); + }); +}); + +describe('useSubmitGameScore', () => { + it('calls submitGameScore on mutate', async () => { + vi.mocked(window.sonos.submitGameScore).mockResolvedValue({ + score: { mainScore: 3, bonusScore: 2, total: 5 }, + } as GameSubmitResult); + const { result } = renderHook(() => useSubmitGameScore(), { wrapper }); + await act(async () => { + await result.current.mutateAsync({ + gameId: 'game-1', + userName: 'alice', + guesses: { main: ['left'], bonus: ['alice'] }, + }); + }); + expect(window.sonos.submitGameScore).toHaveBeenCalledWith({ + gameId: 'game-1', + userName: 'alice', + guesses: { main: ['left'], bonus: ['alice'] }, + }); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useDjSet.test.ts b/apps/mobile/src/logic/hooks/__tests__/useDjSet.test.ts new file mode 100644 index 0000000..db85a80 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useDjSet.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { djSetQueryOptions } from '../useDjSet'; + +beforeEach(() => { + vi.mocked(window.sonos.fetchDjSet).mockReset(); +}); + +describe('djSetQueryOptions', () => { + it('passes the options straight through to the bridge', async () => { + vi.mocked(window.sonos.fetchDjSet).mockResolvedValueOnce({ + tracks: [], + listeners: [], + artistsConsidered: 0, + inferredListeners: false, + }); + const opts = { users: ['Rich'], limit: 5 }; + await djSetQueryOptions(opts).queryFn(); + expect(window.sonos.fetchDjSet).toHaveBeenCalledWith(opts); + }); + + /** + * `main.ts` returns `{ error }` alone when the fetch fails — no arrays at all. + * Components read `data.listeners` directly, so the gaps have to be filled + * here or opening the panel throws. + */ + it('fills in the arrays when the bridge returns only an error', async () => { + vi.mocked(window.sonos.fetchDjSet).mockResolvedValueOnce({ error: 'fetch failed' }); + + const result = await djSetQueryOptions().queryFn(); + + expect(result.tracks).toEqual([]); + expect(result.listeners).toEqual([]); + expect(result.artistsConsidered).toBe(0); + expect(result.error).toBe('fetch failed'); + // The crash was `data.listeners.includes(...)` on this shape. + expect(() => result.listeners.includes('Rich')).not.toThrow(); + }); + + it('survives a null response', async () => { + vi.mocked(window.sonos.fetchDjSet).mockResolvedValueOnce( + null as unknown as DjSetResponse, + ); + const result = await djSetQueryOptions().queryFn(); + expect(result.listeners).toEqual([]); + expect(result.tracks).toEqual([]); + }); + + it('keeps a good response intact', async () => { + vi.mocked(window.sonos.fetchDjSet).mockResolvedValueOnce({ + tracks: [ + { + uri: 'u1', + trackName: 'Loser', + artist: 'Tame Impala', + score: 0.53, + because: 'Sam, Alex and Joe Pitts queue Tame Impala', + }, + ], + listeners: ['Sam', 'Alex'], + artistsConsidered: 409, + inferredListeners: true, + }); + + const result = await djSetQueryOptions().queryFn(); + expect(result.tracks).toHaveLength(1); + expect(result.listeners).toEqual(['Sam', 'Alex']); + expect(result.artistsConsidered).toBe(409); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useDominantColor.test.ts b/apps/mobile/src/logic/hooks/__tests__/useDominantColor.test.ts new file mode 100644 index 0000000..2d69d02 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useDominantColor.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useDominantColor, pickDominantColor, setPixelSampler, setColorExtractor } from '../useDominantColor'; + +describe('pickDominantColor', () => { + it('picks a saturated pixel over grey ones', () => { + expect(pickDominantColor([128, 128, 128, 255, 200, 50, 50, 255])).toBe('200, 50, 50'); + }); + it('falls back to top scorer for dark/grey images', () => { + expect(pickDominantColor([10, 10, 10, 255, 20, 20, 20, 255])).toMatch(/^\d+, \d+, \d+$/); + }); + it('prefers a mid-luminance vibrant pixel', () => { + expect(pickDominantColor([255, 250, 250, 255, 0, 0, 5, 255, 60, 160, 60, 255])).toBe('60, 160, 60'); + }); +}); + +describe('useDominantColor', () => { + it('returns null when src is null', () => { + const { result } = renderHook(() => useDominantColor(null)); + expect(result.current).toBeNull(); + }); + + it('is null with the default sampler', async () => { + setPixelSampler(async () => null); + const { result } = renderHook(() => useDominantColor('http://x/a.jpg')); + await new Promise((r) => setTimeout(r, 10)); + expect(result.current).toBeNull(); + }); + + it('returns a color from an injected sampler and resets on null src', async () => { + setPixelSampler(async () => new Uint8ClampedArray([200, 50, 50, 255])); + const { result, rerender } = renderHook( + ({ src }: { src: string | null }) => useDominantColor(src), + { initialProps: { src: 'http://x/a.jpg' as string | null } }, + ); + expect(result.current).toBeNull(); + await waitFor(() => expect(result.current).toBe('200, 50, 50')); + rerender({ src: null }); + expect(result.current).toBeNull(); + setPixelSampler(async () => null); + }); + + it('prefers an injected color extractor over the pixel sampler', async () => { + setPixelSampler(async () => new Uint8ClampedArray([200, 50, 50, 255])); + setColorExtractor(async () => '1, 2, 3'); + const { result } = renderHook(() => useDominantColor('http://x/b.jpg')); + await waitFor(() => expect(result.current).toBe('1, 2, 3')); + setColorExtractor(null); + setPixelSampler(async () => null); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useGroups.test.ts b/apps/mobile/src/logic/hooks/__tests__/useGroups.test.ts new file mode 100644 index 0000000..80b2059 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useGroups.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useGroups } from '../useGroups'; +import type { NormalizedGroup } from '../../types/provider'; + +const mockOnWsGroups = vi.mocked(window.sonos.onWsGroups); + +beforeEach(() => { + vi.clearAllMocks(); + mockOnWsGroups.mockReturnValue(() => {}); +}); + +const rawGroup = { + id: 'g:1', + coordinatorId: 'RINCON_1', + name: 'Living Room', + playerIds: ['RINCON_1'], +}; + +const normalizedGroup: NormalizedGroup = { + id: 'g:1', + coordinatorId: 'RINCON_1', + name: 'Living Room', + providerId: 'sonos', +}; + +describe('useGroups', () => { + it('returns empty array initially', () => { + const { result } = renderHook(() => useGroups()); + expect(result.current).toEqual([]); + }); + + it('registers onWsGroups listener on mount', () => { + renderHook(() => useGroups()); + expect(mockOnWsGroups).toHaveBeenCalledOnce(); + }); + + it('updates when WS fires group data', () => { + let captured: ((groups: unknown[]) => void) | undefined; + mockOnWsGroups.mockImplementation((cb: (groups: unknown[]) => void) => { captured = cb; return () => {}; }); + + const { result } = renderHook(() => useGroups()); + expect(result.current).toEqual([]); + + act(() => { captured?.([rawGroup]); }); + expect(result.current).toEqual([normalizedGroup]); + }); + + it('calls the unsub function on unmount', () => { + const unsub = vi.fn(); + mockOnWsGroups.mockReturnValue(unsub); + const { unmount } = renderHook(() => useGroups()); + unmount(); + expect(unsub).toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useNowPlaying.test.ts b/apps/mobile/src/logic/hooks/__tests__/useNowPlaying.test.ts new file mode 100644 index 0000000..f4a2b89 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useNowPlaying.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createElement } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import { useNowPlaying } from '../useNowPlaying'; +import type { PlaybackState } from '../usePlayback'; + +// Mock sub-hooks with controllable return values +const mockTrackDetails = vi.fn(); +vi.mock('../useTrackDetails', () => ({ + useTrackDetails: (...args: unknown[]) => mockTrackDetails(...args), +})); + +vi.mock('../useImage', () => ({ useImage: (url: string | null) => url ?? null })); +vi.mock('../useDominantColor', () => ({ useDominantColor: () => null })); + +beforeEach(() => { + vi.clearAllMocks(); + mockTrackDetails.mockReturnValue({ data: undefined }); +}); + +function makePlayback(overrides: Partial = {}): PlaybackState { + return { + isVisible: true, + trackName: 'Default Track', + artistName: 'Default Artist', + artUrl: null, + stateIcon: '▶', + timeLabel: '1:30 / 4:00', + progressPct: 37, + positionMs: 90000, + durationMs: 240000, + isPlaying: true, + shuffle: false, + repeat: 'none', + volume: 50, + currentObjectId: 'obj-1', + currentServiceId: 'svc-1', + currentAccountId: 'acc-1', + currentAlbumId: null, + currentAlbumName: null, + isExplicit: false, + queueId: null, + queueVersion: null, + queueItemId: null, + ...overrides, + }; +} + +function wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient(); + return createElement(QueryClientProvider, { client: qc }, children); +} + +describe('useNowPlaying', () => { + it('passes through trackName and artistName from playback when no track details', () => { + const { result } = renderHook( + () => useNowPlaying(makePlayback({ trackName: 'My Song', artistName: 'My Artist' })), + { wrapper } + ); + expect(result.current.displayTrack).toBe('My Song'); + expect(result.current.displayArtist).toBe('My Artist'); + }); + + it('prefers track details over playback values when available', () => { + mockTrackDetails.mockReturnValue({ data: { trackName: 'Enriched Song', artist: 'Enriched Artist', artUrl: null } }); + const { result } = renderHook( + () => useNowPlaying(makePlayback({ trackName: 'Fallback', artistName: 'Fallback Artist' })), + { wrapper } + ); + expect(result.current.displayTrack).toBe('Enriched Song'); + expect(result.current.displayArtist).toBe('Enriched Artist'); + }); + + it('splits timeLabel into elapsedLabel and durationLabel', () => { + const { result } = renderHook( + () => useNowPlaying(makePlayback({ timeLabel: '2:15 / 5:30' })), + { wrapper } + ); + expect(result.current.elapsedLabel).toBe('2:15'); + expect(result.current.durationLabel).toBe('5:30'); + }); + + it('returns empty strings when timeLabel is empty', () => { + const { result } = renderHook( + () => useNowPlaying(makePlayback({ timeLabel: '' })), + { wrapper } + ); + expect(result.current.elapsedLabel).toBe(''); + expect(result.current.durationLabel).toBe(''); + }); + + it('passes through progressPct, durationMs, isPlaying, isVisible', () => { + const { result } = renderHook( + () => useNowPlaying(makePlayback({ progressPct: 42, durationMs: 180000, isPlaying: false, isVisible: false })), + { wrapper } + ); + expect(result.current.progressPct).toBe(42); + expect(result.current.durationMs).toBe(180000); + expect(result.current.isPlaying).toBe(false); + expect(result.current.isVisible).toBe(false); + }); + + it('returns albumItem when albumId is available in track details', () => { + mockTrackDetails.mockReturnValue({ + data: { + trackName: 'Song', + artist: 'Artist', + artUrl: null, + albumId: 'alb-1', + albumName: 'Great Album', + serviceId: 'svc', + accountId: 'acc', + }, + }); + const { result } = renderHook( + () => useNowPlaying(makePlayback()), + { wrapper } + ); + expect(result.current.albumItem).toBeTruthy(); + expect(result.current.albumItem?.title).toBe('Great Album'); + }); + + it('returns null albumItem when no albumId', () => { + const { result } = renderHook( + () => useNowPlaying(makePlayback({ currentAlbumId: null })), + { wrapper } + ); + expect(result.current.albumItem).toBeNull(); + }); + + it('passes through shuffle, repeat, volume, isExplicit', () => { + const { result } = renderHook( + () => useNowPlaying(makePlayback({ shuffle: true, repeat: 'all', volume: 75, isExplicit: true })), + { wrapper } + ); + expect(result.current.shuffle).toBe(true); + expect(result.current.repeat).toBe('all'); + expect(result.current.volume).toBe(75); + expect(result.current.isExplicit).toBe(true); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useOfficePresence.test.ts b/apps/mobile/src/logic/hooks/__tests__/useOfficePresence.test.ts new file mode 100644 index 0000000..445e2f8 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useOfficePresence.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { officePresenceQueryOptions } from '../useOfficePresence'; + +beforeEach(() => { + vi.mocked(window.sonos.fetchOfficePresence).mockReset(); +}); + +describe('officePresenceQueryOptions', () => { + it('passes through a sensed roster', async () => { + vi.mocked(window.sonos.fetchOfficePresence).mockResolvedValueOnce({ + inOffice: ['Rich', 'Alex'], + basis: 'sensed', + observed: 2, + detail: [ + { userId: 'Rich', availability: 'Available', workLocationType: 'office', source: 'automatic' }, + { userId: 'Sam', availability: 'Available', workLocationType: 'remote', source: 'manual' }, + ], + }); + + const result = await officePresenceQueryOptions().queryFn(); + expect(result.inOffice).toEqual(['Rich', 'Alex']); + expect(result.basis).toBe('sensed'); + }); + + /** No consent, or the scope isn't on the app registration yet. */ + it('degrades to an empty roster so the caller can fall back', async () => { + vi.mocked(window.sonos.fetchOfficePresence).mockResolvedValueOnce({ + inOffice: [], + basis: 'none', + observed: 0, + detail: [], + error: 'no presence consent', + }); + + const result = await officePresenceQueryOptions().queryFn(); + expect(result.inOffice).toEqual([]); + expect(result.basis).toBe('none'); + expect(result.error).toBe('no presence consent'); + }); + + it('survives a malformed response without throwing', async () => { + vi.mocked(window.sonos.fetchOfficePresence).mockResolvedValueOnce( + {} as unknown as OfficePresence, + ); + const result = await officePresenceQueryOptions().queryFn(); + expect(result.inOffice).toEqual([]); + expect(result.detail).toEqual([]); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useOpenItem.test.ts b/apps/mobile/src/logic/hooks/__tests__/useOpenItem.test.ts new file mode 100644 index 0000000..09da1dd --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useOpenItem.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useOpenItem } from '../useOpenItem'; +import type { SonosItem } from '../../types/sonos'; + +const mockNavigate = vi.fn(); + +function setup() { + const { result } = renderHook(() => useOpenItem(mockNavigate)); + return result.current; +} + +describe('useOpenItem', () => { + it('navigates to /artist/:id for an artist item', () => { + const open = setup(); + const item: SonosItem = { + type: 'ARTIST', + resource: { type: 'ARTIST', id: { objectId: 'art-1', serviceId: 'svc' } }, + }; + open(item); + expect(mockNavigate).toHaveBeenCalledWith( + expect.stringMatching(/^\/artist\//), + expect.objectContaining({ state: { item } }) + ); + }); + + it('navigates to /container/:id for a container item', () => { + const open = setup(); + const item: SonosItem = { + type: 'ITEM_PROGRAM', + resource: { type: 'CONTAINER', id: { objectId: 'cont-1' } }, + }; + open(item); + expect(mockNavigate).toHaveBeenCalledWith( + expect.stringMatching(/^\/container\//), + expect.objectContaining({ state: { item } }) + ); + }); + + it('navigates to /album/:id for an album item', () => { + const open = setup(); + const item: SonosItem = { + type: 'ITEM_ALBUM', + resource: { type: 'ALBUM', id: { objectId: 'alb-1', serviceId: 'svc' } }, + }; + open(item); + expect(mockNavigate).toHaveBeenCalledWith( + expect.stringMatching(/^\/album\//), + expect.objectContaining({ state: { item } }) + ); + }); + + it('falls back to _ when albumId is null', () => { + const open = setup(); + const item: SonosItem = { type: 'ITEM_ALBUM' }; + open(item); + expect(mockNavigate).toHaveBeenCalledWith( + '/album/_', + expect.anything() + ); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/usePlayback.test.ts b/apps/mobile/src/logic/hooks/__tests__/usePlayback.test.ts new file mode 100644 index 0000000..8a5f88a --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/usePlayback.test.ts @@ -0,0 +1,469 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { usePlayback } from '../usePlayback'; +import type { PlaybackPayload } from '../../types/sonos'; + +// ── WS callback capture ─────────────────────────────────────────────────────── + +let wsHandler: ((header: unknown, payload: unknown) => void) | null = null; +let readyHandler: (() => void) | null = null; + +beforeEach(() => { + wsHandler = null; + readyHandler = null; + vi.clearAllMocks(); + + vi.mocked(window.sonos.onWsMessage).mockImplementation((cb) => { + wsHandler = cb; + return () => {}; + }); + vi.mocked(window.sonos.onWsReady).mockImplementation((cb) => { + readyHandler = cb; + return () => {}; + }); +}); + +// ── Payload factories ───────────────────────────────────────────────────────── + +function makePayload(overrides: Partial = {}): PlaybackPayload { + return { + playback: { + playbackState: 'PLAYBACK_STATE_PLAYING', + positionMillis: 30000, + queueId: 'q1', + queueVersion: 'v1', + itemId: 'qi1', + playModes: { shuffle: false, repeat: false, repeatOne: false }, + }, + metadata: { + currentItem: { + track: { + name: 'Test Track', + artist: { name: 'Test Artist' }, + durationMillis: 180000, + id: { objectId: 'obj1', serviceId: 'gm', accountId: 'acc1' }, + images: [{ url: 'https://img.example.com/art.jpg' }], + album: { name: 'Test Album', id: { objectId: 'alb1' } }, + explicit: false, + }, + }, + }, + ...overrides, + }; +} + +/** Fire a playbackExtended WS message for the given group. */ +function firePlayback(payload: PlaybackPayload, groupId = 'g1') { + act(() => { wsHandler!({ namespace: 'playbackExtended', groupId }, payload); }); +} + +/** Fire a groupVolume WS message. */ +function fireVolume(volume: number, groupId = 'g1') { + act(() => { wsHandler!({ namespace: 'groupVolume', groupId }, { volume }); }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('usePlayback — initial state', () => { + it('starts with IDLE_STATE (not visible, no track)', () => { + const { result } = renderHook(() => usePlayback('g1')); + expect(result.current.playback.isVisible).toBe(false); + expect(result.current.playback.trackName).toBe(''); + expect(result.current.playback.isPlaying).toBe(false); + }); + + it('exposes stable queueIdRef and queueVersionRef', () => { + const { result } = renderHook(() => usePlayback('g1')); + expect(result.current.queueIdRef.current).toBeNull(); + expect(result.current.queueVersionRef.current).toBeNull(); + }); +}); + +describe('usePlayback — onWsReady', () => { + it('sets isVisible true when the WS session is ready', () => { + const { result } = renderHook(() => usePlayback('g1')); + act(() => { readyHandler!(); }); + expect(result.current.playback.isVisible).toBe(true); + }); +}); + +describe('usePlayback — track name and artist', () => { + it('reads track name and artist object', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload()); + expect(result.current.playback.trackName).toBe('Test Track'); + expect(result.current.playback.artistName).toBe('Test Artist'); + }); + + it('falls back to track.title when name is absent', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + metadata: { currentItem: { track: { title: 'Via Title', artist: 'Someone', durationMillis: 0 } } }, + })); + expect(result.current.playback.trackName).toBe('Via Title'); + }); + + it('handles artist as a plain string', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + metadata: { currentItem: { track: { name: 'T', artist: 'String Artist', durationMillis: 0 } } }, + })); + expect(result.current.playback.artistName).toBe('String Artist'); + }); + + it('returns empty artistName when artist is absent', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + metadata: { currentItem: { track: { name: 'T', durationMillis: 0 } } }, + })); + expect(result.current.playback.artistName).toBe(''); + }); +}); + +describe('usePlayback — progress and timing', () => { + it('computes progressPct as posMs / durMs * 100', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 60000, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 120000 } } }, + })); + expect(result.current.playback.progressPct).toBeCloseTo(50); + }); + + it('clamps progressPct to 100 when posMs > durMs', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 999999, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 180000 } } }, + })); + expect(result.current.playback.progressPct).toBe(100); + }); + + it('sets progressPct to 0 when durMs is 0', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 10000, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0 } } }, + })); + expect(result.current.playback.progressPct).toBe(0); + }); + + it('builds a timeLabel when duration is known', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 65000, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 183000 } } }, + })); + expect(result.current.playback.timeLabel).toBe('1:05 / 3:03'); + }); + + it('sets empty timeLabel when duration is 0', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0 } } }, + })); + expect(result.current.playback.timeLabel).toBe(''); + }); +}); + +describe('usePlayback — playback state', () => { + it('sets isPlaying true and stateIcon ▶ when PLAYING', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + playback: { playbackState: 'PLAYBACK_STATE_PLAYING', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + })); + expect(result.current.playback.isPlaying).toBe(true); + expect(result.current.playback.stateIcon).toBe('▶'); + }); + + it('sets isPlaying false and stateIcon ⏸ when PAUSED', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + playback: { playbackState: 'PLAYBACK_STATE_PAUSED', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + })); + expect(result.current.playback.isPlaying).toBe(false); + expect(result.current.playback.stateIcon).toBe('⏸'); + }); + + it('keeps previous volume on non-NO_GROUPS payload', () => { + const { result } = renderHook(() => usePlayback('g1')); + fireVolume(72); + firePlayback(makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + })); + expect(result.current.playback.volume).toBe(72); + }); + + it('zeros volume when playbackState is NO_GROUPS', () => { + const { result } = renderHook(() => usePlayback('g1')); + fireVolume(80); + firePlayback(makePayload({ + playback: { playbackState: 'NO_GROUPS', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + })); + expect(result.current.playback.volume).toBe(0); + }); +}); + +describe('usePlayback — play modes', () => { + it('sets shuffle true when playModes.shuffle is true', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: { shuffle: true } }, + })); + expect(result.current.playback.shuffle).toBe(true); + }); + + it('sets repeat "one" when repeatOne is true', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: { repeatOne: true } }, + })); + expect(result.current.playback.repeat).toBe('one'); + }); + + it('sets repeat "all" when repeat is true and repeatOne is false', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: { repeat: true, repeatOne: false } }, + })); + expect(result.current.playback.repeat).toBe('all'); + }); + + it('sets repeat "none" when both repeat flags are false', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: { repeat: false, repeatOne: false } }, + })); + expect(result.current.playback.repeat).toBe('none'); + }); +}); + +describe('usePlayback — art URL', () => { + it('picks the first https:// image URL', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + metadata: { currentItem: { track: { + name: 'T', artist: 'A', durationMillis: 0, + images: [ + { url: 'http://insecure.example.com/img.jpg' }, + { url: 'https://secure.example.com/img.jpg' }, + ], + } } }, + })); + expect(result.current.playback.artUrl).toBe('https://secure.example.com/img.jpg'); + }); + + it('falls back to imageUrl when images array has no https entry', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + metadata: { currentItem: { track: { + name: 'T', artist: 'A', durationMillis: 0, + images: [], + imageUrl: 'https://fallback.example.com/img.jpg', + } } }, + })); + expect(result.current.playback.artUrl).toBe('https://fallback.example.com/img.jpg'); + }); + + it('sets artUrl null when no images are available', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0, images: [] } } }, + })); + expect(result.current.playback.artUrl).toBeNull(); + }); +}); + +describe('usePlayback — explicit flag', () => { + it('reads explicit from track.explicit', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0, explicit: true } } }, + })); + expect(result.current.playback.isExplicit).toBe(true); + }); + + it('reads explicit from track.isExplicit (legacy field)', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0, isExplicit: true } as never } }, + })); + expect(result.current.playback.isExplicit).toBe(true); + }); +}); + +describe('usePlayback — album metadata', () => { + it('reads currentAlbumName and currentAlbumId from track.album', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload()); + expect(result.current.playback.currentAlbumName).toBe('Test Album'); + expect(result.current.playback.currentAlbumId).toBe('alb1'); + }); + + it('reads currentObjectId, currentServiceId, currentAccountId from track.id', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload()); + expect(result.current.playback.currentObjectId).toBe('obj1'); + expect(result.current.playback.currentServiceId).toBe('gm'); + expect(result.current.playback.currentAccountId).toBe('acc1'); + }); +}); + +describe('usePlayback — queue cursor refs', () => { + it('updates queueIdRef and queueVersionRef from the payload', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload()); + expect(result.current.queueIdRef.current).toBe('q1'); + expect(result.current.queueVersionRef.current).toBe('v1'); + }); + + it('calls window.sonos.setQueueId when queueId is present', () => { + renderHook(() => usePlayback('g1')); + firePlayback(makePayload()); + expect(window.sonos.setQueueId).toHaveBeenCalledWith('q1'); + }); +}); + +describe('usePlayback — no track name (queue-cursor-only path)', () => { + it('does not set trackName or isVisible when payload has no track name', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + metadata: { currentItem: { track: { durationMillis: 0 } } }, + })); + expect(result.current.playback.trackName).toBe(''); + expect(result.current.playback.isVisible).toBe(false); + }); + + it('still updates queueId in state when track name is absent', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 0, queueId: 'q-new', queueVersion: 'v-new', itemId: 'qi2', playModes: {} }, + metadata: { currentItem: { track: { durationMillis: 0 } } }, + })); + expect(result.current.playback.queueId).toBe('q-new'); + expect(result.current.playback.queueVersion).toBe('v-new'); + expect(result.current.playback.queueItemId).toBe('qi2'); + }); +}); + +describe('usePlayback — group filtering', () => { + it('applies payloads for the active group', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload(), 'g1'); + expect(result.current.playback.trackName).toBe('Test Track'); + }); + + it('ignores payloads for a different group', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload(), 'g2'); + expect(result.current.playback.trackName).toBe(''); + }); + + it('caches payloads for all groups regardless of active group', () => { + const { result } = renderHook(() => usePlayback('g1')); + // Fire a payload for g2 — ignored for state but should be cached + const g2Payload = makePayload({ + metadata: { currentItem: { track: { name: 'G2 Track', artist: 'B', durationMillis: 0 } } }, + }); + firePlayback(g2Payload, 'g2'); + + // Switch to g2 — the cached payload should be applied + act(() => { result.current.applyGroupCache('g2'); }); + expect(result.current.playback.trackName).toBe('G2 Track'); + }); +}); + +describe('usePlayback — groupVolume namespace', () => { + it('updates volume from groupVolume messages', () => { + const { result } = renderHook(() => usePlayback('g1')); + fireVolume(65); + expect(result.current.playback.volume).toBe(65); + }); + + it('does not affect other state fields', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload()); + const trackNameBefore = result.current.playback.trackName; + fireVolume(40); + expect(result.current.playback.trackName).toBe(trackNameBefore); + }); +}); + +describe('usePlayback — applyGroupCache', () => { + it('shows "Nothing playing" when no cache entry exists for the group', () => { + const { result } = renderHook(() => usePlayback('g1')); + act(() => { result.current.applyGroupCache('g2'); }); + expect(result.current.playback.trackName).toBe('Nothing playing'); + expect(result.current.playback.isVisible).toBe(true); + }); + + it('applies cached payload when switching to a group with history', () => { + const { result } = renderHook(() => usePlayback('g1')); + // Cache a payload for g1 by firing a WS message + firePlayback(makePayload({ metadata: { currentItem: { track: { name: 'Cached Song', artist: 'X', durationMillis: 0 } } } }), 'g1'); + + // Switch away and back + act(() => { result.current.applyGroupCache('g2'); }); + act(() => { result.current.applyGroupCache('g1'); }); + expect(result.current.playback.trackName).toBe('Cached Song'); + }); + + it('clears queueVersionRef on group switch (prevents stale etag)', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload()); // populates queueVersionRef = 'v1' + expect(result.current.queueVersionRef.current).toBe('v1'); + + act(() => { result.current.applyGroupCache('g2'); }); + expect(result.current.queueVersionRef.current).toBeNull(); + }); + + it('clears queueIdRef on group switch', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload()); + expect(result.current.queueIdRef.current).toBe('q1'); + + act(() => { result.current.applyGroupCache('g2'); }); + expect(result.current.queueIdRef.current).toBeNull(); + }); + + it('filters out WS messages from the old group immediately after switch', () => { + const { result } = renderHook(() => usePlayback('g1')); + act(() => { result.current.applyGroupCache('g2'); }); + + // A message still tagged g1 should now be ignored + firePlayback(makePayload({ metadata: { currentItem: { track: { name: 'Old Group Track', artist: 'X', durationMillis: 0 } } } }), 'g1'); + expect(result.current.playback.trackName).toBe('Nothing playing'); + }); +}); + +describe('usePlayback — progress tick', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('advances progressPct over time while playing', () => { + const { result } = renderHook(() => usePlayback('g1')); + // Start at 50% (90s / 180s) + firePlayback(makePayload({ + playback: { playbackState: 'PLAYBACK_STATE_PLAYING', positionMillis: 90000, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 180000 } } }, + })); + const initialPct = result.current.playback.progressPct; + + act(() => { vi.advanceTimersByTime(5000); }); + + expect(result.current.playback.progressPct).toBeGreaterThan(initialPct); + }); + + it('does not tick when paused', () => { + const { result } = renderHook(() => usePlayback('g1')); + firePlayback(makePayload({ + playback: { playbackState: 'PLAYBACK_STATE_PAUSED', positionMillis: 90000, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 180000 } } }, + })); + const initialPct = result.current.playback.progressPct; + + act(() => { vi.advanceTimersByTime(5000); }); + + expect(result.current.playback.progressPct).toBe(initialPct); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/usePlaylistBrowse.test.ts b/apps/mobile/src/logic/hooks/__tests__/usePlaylistBrowse.test.ts new file mode 100644 index 0000000..8a0e664 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/usePlaylistBrowse.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createElement } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { parsePlaylistTracks, usePlaylistBrowse } from '../usePlaylistBrowse'; + +vi.mock('../../lib/sonosApi', () => ({ + api: { browse: { playlist: (...args: unknown[]) => mockApiPlaylist(...args) } }, +})); + +const mockApiPlaylist = vi.fn(); +const mockFetch = vi.mocked(window.sonos.fetch); + +beforeEach(() => { + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ data: null }); +}); + +function makeTrack(title: string, overrides: Record = {}) { + return { + title, + duration: 'PT3M26S', + images: { tile1x1: `https://art/${title}.jpg` }, + resource: { id: { objectId: `obj-${title}`, serviceId: 'svc', accountId: 'acc' } }, + artists: [{ name: 'Artist One', id: 'srn:content:audio:artist:art-1#en' }], + isExplicit: false, + ...overrides, + }; +} + +// ─── parsePlaylistTracks ────────────────────────────────────────────────────── + +describe('parsePlaylistTracks', () => { + it('returns empty array for empty data', () => { + expect(parsePlaylistTracks({ tracks: { items: [] } })).toEqual([]); + }); + + it('parses title, ordinal, durationSeconds from tracks', () => { + const result = parsePlaylistTracks({ + tracks: { items: [makeTrack('Song A'), makeTrack('Song B')] }, + }); + expect(result).toHaveLength(2); + expect(result[0].title).toBe('Song A'); + expect(result[0].ordinal).toBe(1); + expect(result[0].durationSeconds).toBe(206); // 3*60+26 + expect(result[1].ordinal).toBe(2); + }); + + it('parses ISO 8601 duration with hours', () => { + const result = parsePlaylistTracks({ + tracks: { items: [makeTrack('Long', { duration: 'PT1H2M3S' })] }, + }); + expect(result[0].durationSeconds).toBe(3723); // 3600+120+3 + }); + + it('falls back to 0 for missing duration', () => { + const result = parsePlaylistTracks({ + tracks: { items: [makeTrack('No Dur', { duration: undefined })] }, + }); + expect(result[0].durationSeconds).toBe(0); + }); + + it('handles numeric duration', () => { + const result = parsePlaylistTracks({ + tracks: { items: [makeTrack('Num', { duration: 180 })] }, + }); + expect(result[0].durationSeconds).toBe(180); + }); + + it('strips artistId prefix and hash from artistObjects', () => { + const result = parsePlaylistTracks({ + tracks: { items: [makeTrack('Song')] }, + }); + expect(result[0].artistObjects?.[0].objectId).toBe('art-1'); + }); + + it('collects artist names into artists array', () => { + const result = parsePlaylistTracks({ + tracks: { items: [makeTrack('Song', { artists: [{ name: 'Alpha', id: 'id-1' }, { name: 'Beta', id: 'id-2' }] })] }, + }); + expect(result[0].artists).toEqual(['Alpha', 'Beta']); + }); + + it('sets artUrl from tile1x1', () => { + const result = parsePlaylistTracks({ + tracks: { items: [makeTrack('Song')] }, + }); + expect(result[0].artUrl).toBe('https://art/Song.jpg'); + }); + + it('returns null artUrl when no images', () => { + const result = parsePlaylistTracks({ + tracks: { items: [makeTrack('Song', { images: undefined })] }, + }); + expect(result[0].artUrl).toBeNull(); + }); + + it('sets explicit flag', () => { + const result = parsePlaylistTracks({ + tracks: { items: [makeTrack('Exp', { isExplicit: true })] }, + }); + expect(result[0].explicit).toBe(true); + }); +}); + +// ─── usePlaylistBrowse hook ─────────────────────────────────────────────────── + +function wrapper({ children }: { children: React.ReactNode }) { + return createElement(QueryClientProvider, { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, children); +} + +describe('usePlaylistBrowse', () => { + beforeEach(() => { mockApiPlaylist.mockReset(); }); + + it('is disabled when playlistId is undefined', () => { + const { result } = renderHook( + () => usePlaylistBrowse(undefined, 'svc', 'acc'), + { wrapper } + ); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + + it('fetches and parses tracks when enabled', async () => { + mockApiPlaylist.mockResolvedValue({ + error: null, + data: { tracks: { items: [makeTrack('Fetched Song')] } }, + }); + const { result } = renderHook( + () => usePlaylistBrowse('pl-1', 'svc', 'acc'), + { wrapper } + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data?.[0]?.title).toBe('Fetched Song'); + }); + + it('throws when api returns error', async () => { + mockApiPlaylist.mockResolvedValue({ error: 'Not found', data: null }); + const { result } = renderHook( + () => usePlaylistBrowse('pl-1', 'svc', 'acc'), + { wrapper } + ); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/usePlaylists.test.ts b/apps/mobile/src/logic/hooks/__tests__/usePlaylists.test.ts new file mode 100644 index 0000000..7eaf87b --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/usePlaylists.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { createElement } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useFavourite, useEnsureFavourites, favouritesId } from '../usePlaylists'; + +const mockShowToast = vi.fn(); +vi.mock('../../context/ToastContext', () => ({ + useToast: () => ({ showToast: mockShowToast }), +})); + +function makeWrapper(qc: QueryClient) { + return ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: qc }, children); +} + +function makeQc() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +const DISPLAY_NAME = 'Alice'; +const TRACK_URI = 'x-sonos-spotify:spotify:track:abc123'; + +const sampleTrack = { + uri: TRACK_URI, + trackName: 'Test Track', + artist: 'Test Artist', + albumName: 'Test Album', + imageUrl: null as null, + serviceId: 'gm', + accountId: 'acc1', +}; + +function makePlaylistTrack(): PlaylistTrack { + return { + uri: TRACK_URI, + trackName: 'Test Track', + artist: 'Test Artist', + imageUrl: null, + serviceId: 'gm', + accountId: 'acc1', + addedBy: DISPLAY_NAME, + addedAt: 0, + }; +} + +function makeFavPlaylist(tracks: PlaylistTrack[] = []): PlaylistDoc { + const id = favouritesId(DISPLAY_NAME); + return { + id, + name: 'Favourites', + owner: DISPLAY_NAME, + isPublic: false, + isFavourites: true, + members: [DISPLAY_NAME], + memberCount: 1, + trackCount: tracks.length, + tracks, + createdAt: 0, + updatedAt: 0, + }; +} + +describe('useFavourite', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns isFavourited=false when playlist is not in cache', () => { + const qc = makeQc(); + const { result } = renderHook( + () => useFavourite(DISPLAY_NAME, sampleTrack), + { wrapper: makeWrapper(qc) }, + ); + expect(result.current.isFavourited).toBe(false); + }); + + it('returns isFavourited=true when track uri matches cached playlist', () => { + const qc = makeQc(); + const favId = favouritesId(DISPLAY_NAME); + qc.setQueryData(['playlist', favId], makeFavPlaylist([makePlaylistTrack()])); + + const { result } = renderHook( + () => useFavourite(DISPLAY_NAME, sampleTrack), + { wrapper: makeWrapper(qc) }, + ); + expect(result.current.isFavourited).toBe(true); + }); + + it('toggle add: calls addTrackToPlaylist and updates cache optimistically', async () => { + const qc = makeQc(); + const favId = favouritesId(DISPLAY_NAME); + qc.setQueryData(['playlist', favId], makeFavPlaylist()); + vi.mocked(window.sonos.addTrackToPlaylist).mockResolvedValueOnce({} as PlaylistDoc); + + const { result } = renderHook( + () => useFavourite(DISPLAY_NAME, sampleTrack), + { wrapper: makeWrapper(qc) }, + ); + + await act(() => result.current.toggle()); + + expect(vi.mocked(window.sonos.addTrackToPlaylist)).toHaveBeenCalledWith( + favId, + expect.objectContaining({ uri: TRACK_URI, addedBy: DISPLAY_NAME }), + ); + const updated = qc.getQueryData(['playlist', favId]); + expect(updated?.tracks).toHaveLength(1); + expect(updated?.tracks[0].uri).toBe(TRACK_URI); + }); + + it('toggle add rollback: restores cache and shows toast when server fails', async () => { + const qc = makeQc(); + const favId = favouritesId(DISPLAY_NAME); + qc.setQueryData(['playlist', favId], makeFavPlaylist()); + vi.mocked(window.sonos.addTrackToPlaylist).mockRejectedValueOnce(new Error('network')); + + const { result } = renderHook( + () => useFavourite(DISPLAY_NAME, sampleTrack), + { wrapper: makeWrapper(qc) }, + ); + + await act(() => result.current.toggle()); + + const restored = qc.getQueryData(['playlist', favId]); + expect(restored?.tracks).toHaveLength(0); + expect(mockShowToast).toHaveBeenCalledWith('Failed to add to Favourites'); + }); + + it('toggle remove: calls removeTrackFromPlaylist and removes from cache', async () => { + const qc = makeQc(); + const favId = favouritesId(DISPLAY_NAME); + qc.setQueryData(['playlist', favId], makeFavPlaylist([makePlaylistTrack()])); + vi.mocked(window.sonos.removeTrackFromPlaylist).mockResolvedValueOnce({} as PlaylistDoc); + + const { result } = renderHook( + () => useFavourite(DISPLAY_NAME, sampleTrack), + { wrapper: makeWrapper(qc) }, + ); + + expect(result.current.isFavourited).toBe(true); + await act(() => result.current.toggle()); + + expect(vi.mocked(window.sonos.removeTrackFromPlaylist)).toHaveBeenCalledWith(favId, TRACK_URI); + const updated = qc.getQueryData(['playlist', favId]); + expect(updated?.tracks).toHaveLength(0); + }); + + it('toggle remove rollback: restores cache and shows toast when server fails', async () => { + const qc = makeQc(); + const favId = favouritesId(DISPLAY_NAME); + qc.setQueryData(['playlist', favId], makeFavPlaylist([makePlaylistTrack()])); + vi.mocked(window.sonos.removeTrackFromPlaylist).mockRejectedValueOnce(new Error('network')); + + const { result } = renderHook( + () => useFavourite(DISPLAY_NAME, sampleTrack), + { wrapper: makeWrapper(qc) }, + ); + + expect(result.current.isFavourited).toBe(true); + await act(() => result.current.toggle()); + + const restored = qc.getQueryData(['playlist', favId]); + expect(restored?.tracks).toHaveLength(1); + expect(mockShowToast).toHaveBeenCalledWith('Failed to remove from Favourites'); + }); +}); + +describe('useEnsureFavourites', () => { + it('registers query with staleTime=5 minutes and retry=2', () => { + const qc = new QueryClient(); + renderHook(() => useEnsureFavourites(DISPLAY_NAME), { wrapper: makeWrapper(qc) }); + const query = qc.getQueryCache().find({ queryKey: ['favourites-ensure', DISPLAY_NAME] }); + const opts = query?.options as { staleTime?: number; retry?: number | boolean | null } | undefined; + expect(opts?.staleTime).toBe(5 * 60_000); + expect(opts?.retry).toBe(2); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useQueue.test.ts b/apps/mobile/src/logic/hooks/__tests__/useQueue.test.ts new file mode 100644 index 0000000..7b957c3 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useQueue.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useQueue } from '../useQueue'; + +const mockFetch = vi.mocked(window.sonos.fetch); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +function queueResponse(items: unknown[] = [], etag?: string) { + return { data: { items }, ...(etag ? { etag } : {}) }; +} + +// ─── initial load ───────────────────────────────────────────────────────────── + +describe('useQueue — initial load', () => { + it('does not fetch when not authed', () => { + renderHook(() => useQueue(false, 'g1', 'q1')); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('does not fetch when activeGroupId is null', () => { + renderHook(() => useQueue(true, null, 'q1')); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('fetches queue when authed with a group', async () => { + mockFetch.mockResolvedValue(queueResponse()); + renderHook(() => useQueue(true, 'g1', 'q1')); + await waitFor(() => + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ operationId: 'getQueueResources' }), + ), + ); + }); + + it('fires onEtag with etag from first page', async () => { + mockFetch.mockResolvedValue(queueResponse([], 'etag-v1')); + const onEtag = vi.fn(); + renderHook(() => useQueue(true, 'g1', 'q1', onEtag)); + await waitFor(() => expect(onEtag).toHaveBeenCalledWith('etag-v1')); + }); + + it('does not fire onEtag when response has no etag', async () => { + mockFetch.mockResolvedValue(queueResponse()); + const onEtag = vi.fn(); + renderHook(() => useQueue(true, 'g1', 'q1', onEtag)); + await waitFor(() => expect(mockFetch).toHaveBeenCalled()); + await act(async () => {}); + expect(onEtag).not.toHaveBeenCalled(); + }); + + it('does not fire onEtag when list returns an error', async () => { + mockFetch.mockResolvedValue({ data: null, error: 'server error' }); + const onEtag = vi.fn(); + renderHook(() => useQueue(true, 'g1', 'q1', onEtag)); + await waitFor(() => expect(mockFetch).toHaveBeenCalled()); + await act(async () => {}); + expect(onEtag).not.toHaveBeenCalled(); + }); + + it('sets items from response', async () => { + const items = [{ type: 'TRACK', name: 'Song A' }]; + mockFetch.mockResolvedValue(queueResponse(items)); + const { result } = renderHook(() => useQueue(true, 'g1', 'q1')); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + expect(result.current.items[0].track.title).toBe('Song A'); + }); + + it('clears isLoading after load completes', async () => { + mockFetch.mockResolvedValue(queueResponse()); + const { result } = renderHook(() => useQueue(true, 'g1', 'q1')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + }); + + it('sets error when list call fails', async () => { + mockFetch.mockResolvedValue({ data: null, error: 'not found' }); + const { result } = renderHook(() => useQueue(true, 'g1', 'q1')); + await waitFor(() => expect(result.current.error).toBe('not found')); + }); +}); + +// ─── pagination ─────────────────────────────────────────────────────────────── + +describe('useQueue — pagination', () => { + it('fetches a second page when first page is full (50 items)', async () => { + const page1 = Array.from({ length: 50 }, (_, i) => ({ type: 'TRACK', name: `T${i}` })); + const page2 = [{ type: 'TRACK', name: 'Last' }]; + mockFetch + .mockResolvedValueOnce({ data: { items: page1 }, etag: 'v1' }) + .mockResolvedValueOnce({ data: { items: page2 } }); + const { result } = renderHook(() => useQueue(true, 'g1', 'q1')); + await waitFor(() => expect(result.current.items).toHaveLength(51)); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('fires onEtag only once, from the first page', async () => { + const page1 = Array.from({ length: 50 }, (_, i) => ({ type: 'TRACK', name: `T${i}` })); + mockFetch + .mockResolvedValueOnce({ data: { items: page1 }, etag: 'etag-page1' }) + .mockResolvedValueOnce({ data: { items: [] } }); + const onEtag = vi.fn(); + renderHook(() => useQueue(true, 'g1', 'q1', onEtag)); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); + await act(async () => {}); + expect(onEtag).toHaveBeenCalledTimes(1); + expect(onEtag).toHaveBeenCalledWith('etag-page1'); + }); + + it('stops fetching when a page returns fewer than 50 items', async () => { + const items = Array.from({ length: 25 }, (_, i) => ({ name: `T${i}` })); + mockFetch.mockResolvedValue({ data: { items } }); + const { result } = renderHook(() => useQueue(true, 'g1', 'q1')); + await waitFor(() => expect(result.current.items).toHaveLength(25)); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); + +// ─── reload ─────────────────────────────────────────────────────────────────── + +describe('useQueue — reload', () => { + it('re-fetches and fires onEtag with the fresh etag', async () => { + mockFetch + .mockResolvedValueOnce(queueResponse([], 'etag-initial')) + .mockResolvedValueOnce(queueResponse([], 'etag-fresh')); + const onEtag = vi.fn(); + const { result } = renderHook(() => useQueue(true, 'g1', 'q1', onEtag)); + await waitFor(() => expect(onEtag).toHaveBeenCalledWith('etag-initial')); + + await act(async () => { + await result.current.reload(); + }); + + expect(onEtag).toHaveBeenCalledTimes(2); + expect(onEtag).toHaveBeenLastCalledWith('etag-fresh'); + }); + + it('updates items on reload', async () => { + mockFetch + .mockResolvedValueOnce(queueResponse([{ name: 'Old Song' }])) + .mockResolvedValueOnce(queueResponse([{ name: 'New Song' }])); + const { result } = renderHook(() => useQueue(true, 'g1', 'q1')); + await waitFor(() => expect(result.current.items[0]?.track.title).toBe('Old Song')); + await act(async () => { + await result.current.reload(); + }); + expect(result.current.items[0]?.track.title).toBe('New Song'); + }); + + it('does not fire onEtag on reload when list response has no etag', async () => { + mockFetch + .mockResolvedValueOnce(queueResponse([], 'etag-initial')) + .mockResolvedValueOnce(queueResponse([])); + const onEtag = vi.fn(); + const { result } = renderHook(() => useQueue(true, 'g1', 'q1', onEtag)); + await waitFor(() => expect(onEtag).toHaveBeenCalledWith('etag-initial')); + onEtag.mockClear(); + + await act(async () => { + await result.current.reload(); + }); + + expect(onEtag).not.toHaveBeenCalled(); + }); +}); + +// ─── reload error handling ──────────────────────────────────────────────────── + +describe('useQueue — reload error handling', () => { + it('keeps existing items visible and routes the error to onReloadError when a background reload fails', async () => { + mockFetch + .mockResolvedValueOnce(queueResponse([{ name: 'Old Song' }], 'etag-v1')) + .mockResolvedValueOnce({ data: null, error: '500 Internal Server Error' }); + const onReloadError = vi.fn(); + const { result } = renderHook(() => + useQueue(true, 'g1', 'q1', undefined, onReloadError), + ); + await waitFor(() => expect(result.current.items[0]?.track.title).toBe('Old Song')); + + await act(async () => { + await result.current.reload(); + }); + + expect(result.current.error).toBeNull(); + expect(result.current.items[0]?.track.title).toBe('Old Song'); + expect(onReloadError).toHaveBeenCalledWith('500 Internal Server Error'); + }); + + it('still surfaces the error in state when the very first load fails (no items yet)', async () => { + mockFetch.mockResolvedValueOnce({ data: null, error: '500 Internal Server Error' }); + const onReloadError = vi.fn(); + const { result } = renderHook(() => + useQueue(true, 'g1', 'q1', undefined, onReloadError), + ); + await waitFor(() => expect(result.current.error).toBe('500 Internal Server Error')); + expect(result.current.items).toHaveLength(0); + expect(onReloadError).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useQueueTrack.test.ts b/apps/mobile/src/logic/hooks/__tests__/useQueueTrack.test.ts new file mode 100644 index 0000000..fddfeb6 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useQueueTrack.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createElement } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import { useQueueTrack } from '../useQueueTrack'; +import type { NormalizedQueueItem } from '../../types/provider'; + +const mockTrackDetails = vi.fn(); +vi.mock('../useTrackDetails', () => ({ + useTrackDetails: (...args: unknown[]) => mockTrackDetails(...args), +})); +vi.mock('../useAlbumBrowse', () => ({ albumQueryOptions: vi.fn() })); +vi.mock('../useArtistBrowse', () => ({ artistQueryOptions: vi.fn() })); + +function wrapper({ children }: { children: React.ReactNode }) { + return createElement(QueryClientProvider, { client: new QueryClient() }, children); +} + +const baseItem: NormalizedQueueItem = { + index: 0, + track: { + id: 'obj-1', + title: 'Come Together', + artist: '', + albumName: null, + albumId: null, + imageUrl: null, + durationMs: 0, + isExplicit: false, + serviceId: 'svc-1', + accountId: 'acc-1', + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockTrackDetails.mockReturnValue({ data: undefined }); +}); + +describe('useQueueTrack', () => { + it('returns artist and artUrl from item when no track details', () => { + const { result } = renderHook( + () => useQueueTrack(baseItem, null), + { wrapper } + ); + expect(result.current.artist).toBeDefined(); + expect(result.current.explicit).toBe(false); + }); + + it('prefers track details over raw item values', () => { + mockTrackDetails.mockReturnValue({ + data: { artist: 'Enriched Artist', artUrl: 'http://img.com/art.jpg', albumName: 'Abbey Road', albumId: null, serviceId: null, accountId: null, artistId: null }, + }); + const { result } = renderHook( + () => useQueueTrack(baseItem, null), + { wrapper } + ); + expect(result.current.artist).toBe('Enriched Artist'); + expect(result.current.artUrl).toBe('http://img.com/art.jpg'); + }); + + it('isPlaying is true when currentObjectId matches item objectId', () => { + const { result } = renderHook( + () => useQueueTrack(baseItem, 'obj-1'), + { wrapper } + ); + expect(result.current.isPlaying).toBe(true); + }); + + it('isPlaying is false when currentObjectId does not match', () => { + const { result } = renderHook( + () => useQueueTrack(baseItem, 'other-obj'), + { wrapper } + ); + expect(result.current.isPlaying).toBe(false); + }); + + it('returns albumItem when albumId and albumName available in track details', () => { + mockTrackDetails.mockReturnValue({ + data: { + artist: 'Artist', + artUrl: null, + albumName: 'Abbey Road', + albumId: 'alb-1', + serviceId: 'svc-1', + accountId: 'acc-1', + artistId: null, + }, + }); + const { result } = renderHook( + () => useQueueTrack(baseItem, null), + { wrapper } + ); + expect(result.current.albumItem).not.toBeNull(); + expect(result.current.albumItem?.title).toBe('Abbey Road'); + }); + + it('returns null albumItem when albumId is missing', () => { + mockTrackDetails.mockReturnValue({ + data: { artist: 'Artist', artUrl: null, albumName: null, albumId: null, serviceId: null, accountId: null, artistId: null }, + }); + const { result } = renderHook( + () => useQueueTrack(baseItem, null), + { wrapper } + ); + expect(result.current.albumItem).toBeNull(); + }); + + it('returns artistItem when artistId available in track details', () => { + mockTrackDetails.mockReturnValue({ + data: { + artist: 'The Beatles', + artUrl: null, + albumName: null, + albumId: null, + serviceId: 'svc-1', + accountId: 'acc-1', + artistId: 'artist-1', + }, + }); + const { result } = renderHook( + () => useQueueTrack(baseItem, null), + { wrapper } + ); + expect(result.current.artistItem).not.toBeNull(); + expect(result.current.artistItem?.name).toBe('The Beatles'); + }); + + it('returns null artistItem when artistId is missing', () => { + const { result } = renderHook( + () => useQueueTrack(baseItem, null), + { wrapper } + ); + expect(result.current.artistItem).toBeNull(); + }); + + it('explicit is true when track.isExplicit is set', () => { + const explicitItem: NormalizedQueueItem = { + ...baseItem, + track: { ...baseItem.track, isExplicit: true }, + }; + const { result } = renderHook( + () => useQueueTrack(explicitItem, null), + { wrapper } + ); + expect(result.current.explicit).toBe(true); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useSkinnyMode.test.ts b/apps/mobile/src/logic/hooks/__tests__/useSkinnyMode.test.ts new file mode 100644 index 0000000..eb4a3e0 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useSkinnyMode.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('react-native', () => ({ useWindowDimensions: () => ({ width: 1280, height: 800 }) })); + +import { computeSkinny, FULL_MIN } from '../useSkinnyMode'; + +describe('computeSkinny', () => { + it('goes skinny below FULL_MIN', () => { + expect(computeSkinny(FULL_MIN - 1, false)).toBe(true); + expect(computeSkinny(FULL_MIN, false)).toBe(false); + }); + it('stays skinny until past the exit buffer', () => { + expect(computeSkinny(FULL_MIN + 40, true)).toBe(true); + expect(computeSkinny(FULL_MIN + 41, true)).toBe(false); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useStats.test.ts b/apps/mobile/src/logic/hooks/__tests__/useStats.test.ts new file mode 100644 index 0000000..d845a80 --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useStats.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { QueryClient } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { createElement } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { useStats } from '../useStats'; + +const mockFetchStats = vi.mocked(window.sonos.fetchStats); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return createElement(QueryClientProvider, { client: qc }, children); +} + +describe('useStats', () => { + it('calls window.sonos.fetchStats with the given period', async () => { + const data = { topUsers: [], topTracks: [], topArtists: [], topAlbums: [], totalEvents: 0, periodStart: 0 }; + mockFetchStats.mockResolvedValue(data); + + const { result } = renderHook(() => useStats('today'), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mockFetchStats).toHaveBeenCalledWith('today', undefined); + expect(result.current.data).toEqual(data); + }); + + it('passes userId when provided', async () => { + mockFetchStats.mockResolvedValue({ topUsers: [], topTracks: [], topArtists: [], topAlbums: [], totalEvents: 0, periodStart: 0 }); + const { result } = renderHook(() => useStats('week', 'alice'), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(mockFetchStats).toHaveBeenCalledWith('week', 'alice'); + }); + + it('returns error state when fetch fails', async () => { + mockFetchStats.mockRejectedValue(new Error('Network error')); + const { result } = renderHook(() => useStats('alltime'), { wrapper }); + // retry:1 means two attempts before error is set; give extra time + await waitFor(() => expect(result.current.error).toBeTruthy(), { timeout: 5000 }); + }); +}); diff --git a/apps/mobile/src/logic/hooks/__tests__/useTrackDetails.test.ts b/apps/mobile/src/logic/hooks/__tests__/useTrackDetails.test.ts new file mode 100644 index 0000000..8bee31c --- /dev/null +++ b/apps/mobile/src/logic/hooks/__tests__/useTrackDetails.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { trackQueryOptions } from '../useTrackDetails'; + +const mockFetch = vi.mocked(window.sonos.fetch); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +// Build a minimal nowPlaying response shape that parseResponse understands. +function makeResponse(overrides: Record = {}) { + return { data: { title: 'Default Title', ...overrides } }; +} + +// Encode defaults the same way the Sonos API does — btoa(JSON.stringify(...)). +function encodeDefaults(obj: Record) { + return btoa(JSON.stringify(obj)); +} + +// ─── accountId normalisation ────────────────────────────────────────────────── + +describe('accountId normalisation', () => { + it('strips sn_ prefix from accountId before sending the API request', async () => { + mockFetch.mockResolvedValueOnce(makeResponse()); + const opts = trackQueryOptions('trk-1', 'gm', 'sn_acc123'); + await opts.queryFn(); + // The fetch call must use 'acc123', not 'sn_acc123' + const call = mockFetch.mock.calls[0][0]; + expect(call.pathParams?.accountId).toBe('acc123'); + }); + + it('leaves accountId unchanged when no sn_ prefix', async () => { + mockFetch.mockResolvedValueOnce(makeResponse()); + const opts = trackQueryOptions('trk-1', 'gm', 'acc123'); + await opts.queryFn(); + const call = mockFetch.mock.calls[0][0]; + expect(call.pathParams?.accountId).toBe('acc123'); + }); +}); + +// ─── parseResponse — title ──────────────────────────────────────────────────── + +describe('title parsing', () => { + it('reads title from top-level title field', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ title: 'Come Together' })); + const result = await trackQueryOptions('t', 'gm', 'a').queryFn(); + expect(result?.trackName).toBe('Come Together'); + }); + + it('falls back to item.title when top-level title is absent', async () => { + mockFetch.mockResolvedValueOnce({ data: { item: { title: 'Hey Jude' } } }); + const result = await trackQueryOptions('t', 'gm', 'a').queryFn(); + expect(result?.trackName).toBe('Hey Jude'); + }); +}); + +// ─── parseResponse — artUrl ─────────────────────────────────────────────────── + +describe('artUrl parsing', () => { + it('reads artUrl from top-level images.tile1x1', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ images: { tile1x1: 'http://img/top.jpg' } })); + const result = await trackQueryOptions('t', 'gm', 'a').queryFn(); + expect(result?.artUrl).toBe('http://img/top.jpg'); + }); + + it('falls back to item.images.tile1x1 when top-level images absent', async () => { + mockFetch.mockResolvedValueOnce({ data: { item: { images: { tile1x1: 'http://img/item.jpg' } } } }); + const result = await trackQueryOptions('t', 'gm', 'a').queryFn(); + expect(result?.artUrl).toBe('http://img/item.jpg'); + }); +}); + +// ─── parseResponse — artist ─────────────────────────────────────────────────── + +describe('artist parsing', () => { + it('reads artist from top-level subtitle', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ subtitle: 'The Beatles' })); + const result = await trackQueryOptions('t', 'gm', 'a').queryFn(); + expect(result?.artist).toBe('The Beatles'); + }); + + it('falls back to item.subtitle', async () => { + mockFetch.mockResolvedValueOnce({ data: { item: { subtitle: 'Led Zeppelin' } } }); + const result = await trackQueryOptions('t', 'gm', 'a').queryFn(); + expect(result?.artist).toBe('Led Zeppelin'); + }); + + it('falls back to item.artists[0].name when no subtitle', async () => { + mockFetch.mockResolvedValueOnce({ data: { item: { artists: [{ name: 'Pink Floyd' }] } } }); + const result = await trackQueryOptions('t', 'gm', 'a').queryFn(); + expect(result?.artist).toBe('Pink Floyd'); + }); +}); + +// ─── parseResponse — album + artistId from defaults ────────────────────────── + +describe('defaults parsing', () => { + it('extracts albumName, albumId, and artistId from base64-encoded defaults', async () => { + const defaults = encodeDefaults({ containerName: 'Abbey Road', containerId: 'alb-1', artistId: 'art-1' }); + mockFetch.mockResolvedValueOnce({ + data: { item: { resource: { id: { serviceId: 'gm', accountId: 'acc' }, defaults } } }, + }); + const result = await trackQueryOptions('t', 'gm', 'a').queryFn(); + expect(result?.albumName).toBe('Abbey Road'); + expect(result?.albumId).toBe('alb-1'); + expect(result?.artistId).toBe('art-1'); + }); + + it('extracts serviceId and accountId from resource.id', async () => { + mockFetch.mockResolvedValueOnce({ + data: { item: { resource: { id: { serviceId: 'sp', accountId: 'acc-sp' } } } }, + }); + const result = await trackQueryOptions('t', 'gm', 'a').queryFn(); + expect(result?.serviceId).toBe('sp'); + expect(result?.accountId).toBe('acc-sp'); + }); +}); + +// ─── YT Music fallback ──────────────────────────────────────────────────────── + +describe('YT Music fallback', () => { + it('calls YT Music (72711) when primary service errors', async () => { + mockFetch + .mockResolvedValueOnce({ data: null, error: 'not found' }) + .mockResolvedValueOnce(makeResponse({ title: 'Via YT' })); + + const result = await trackQueryOptions('t', 'gm', 'a').queryFn(); + + expect(result?.trackName).toBe('Via YT'); + expect(mockFetch).toHaveBeenCalledTimes(2); + const secondCall = mockFetch.mock.calls[1][0]; + expect(secondCall.pathParams?.serviceId).toBe('72711'); + }); + + it('returns null when both primary and YT fallback fail', async () => { + mockFetch + .mockResolvedValueOnce({ data: null, error: 'fail' }) + .mockResolvedValueOnce({ data: null, error: 'fail' }); + + const result = await trackQueryOptions('t', 'gm', 'a').queryFn(); + expect(result).toBeNull(); + }); + + it('does not attempt YT fallback when serviceId is already 72711', async () => { + mockFetch.mockResolvedValueOnce({ data: null, error: 'fail' }); + + const result = await trackQueryOptions('t', '72711', 'a').queryFn(); + expect(result).toBeNull(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/logic/hooks/useAlbumBrowse.ts b/apps/mobile/src/logic/hooks/useAlbumBrowse.ts new file mode 100644 index 0000000..483b71c --- /dev/null +++ b/apps/mobile/src/logic/hooks/useAlbumBrowse.ts @@ -0,0 +1,126 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/sonosApi"; +import type { SonosItem, SonosItemId } from "../types/sonos"; +import { AlbumResponse } from "../types/AlbumResponse"; +import { decodeDefaults } from "../lib/itemHelpers"; + +function cleanArtistId(id: string): string { + return id.replace(/^srn:content:audio:artist:/, '').replace(/#.*$/, ''); +} + +export interface AlbumTrack { + title: string; + ordinal: number; + durationSeconds: number; + artUrl: string | null; + id: SonosItemId; + artists: string[]; + /** Only populated for playlist tracks — carries objectId so artist is navigable. */ + artistObjects?: Array<{ name: string; objectId: string }>; + /** Album/container name and id — decoded from track defaults, populated for playlist tracks. */ + albumName: string | null; + albumId: string | null; + explicit: boolean; + raw: SonosItem; +} + +export interface AlbumData { + title: string; + artist: string; + artUrl: string | null; + tracks: AlbumTrack[]; + totalTracks: number; + artistItem: SonosItem | null; +} + +type RawTrack = NonNullable["items"][number]; + +function parseTrack(track: RawTrack): AlbumTrack { + return { + title: track.title ?? "", + ordinal: track.ordinal ?? 0, + durationSeconds: Number(track.duration ?? 0), + artUrl: track.images?.tile1x1 ?? null, + id: (track.resource?.id ?? {}) as SonosItemId, + artists: track.artists?.map((a) => a.name) ?? [], + artistObjects: track.artists?.filter((a) => a.id).map((a) => ({ name: a.name, objectId: cleanArtistId(a.id as string) })), + albumName: null, + albumId: null, + explicit: track.isExplicit ?? false, + raw: track as unknown as SonosItem, + }; +} + +function parseAlbum(data: AlbumResponse): AlbumData { + const title = data.title ?? ""; + const artist = data.subtitle ?? ""; + const artUrl = data.images?.tile1x1 ?? null; + + // Decode album defaults to find the artist's objectId + const decoded = decodeDefaults(data.resource?.defaults); + const artistId = decoded?.["artistId"] as string | undefined; + const serviceId = data.resource?.id?.serviceId; + const accountId = data.resource?.id?.accountId; + const artistItem: SonosItem | null = + artistId && serviceId && accountId + ? ({ + type: "ARTIST", + title: (decoded?.["artist"] as string | undefined) ?? artist, + resource: { + type: "ARTIST", + id: { objectId: artistId, serviceId, accountId }, + defaults: undefined, + }, + } as SonosItem) + : null; + + const rawTracks = data.tracks?.items ?? []; + const totalTracks = data.tracks?.total ?? rawTracks.length; + const tracks = rawTracks.map(parseTrack); + + return { title, artist, artUrl, tracks, totalTracks, artistItem }; +} + +export function albumQueryOptions( + albumId: string | undefined, + serviceId: string | undefined, + accountId: string | undefined, + defaults: string | undefined, +) { + return { + queryKey: ["album", albumId] as const, + queryFn: async () => { + const r = await api.browse.album(albumId!, { serviceId, accountId, defaults, muse2: true, count: 50 }); + if (r.error) throw new Error(r.error); + const parsed = parseAlbum(r.data as AlbumResponse); + + // Fetch remaining pages if the album has more than 50 tracks + let allTracks = [...parsed.tracks]; + let offset = allTracks.length; + while (offset < parsed.totalTracks) { + const page = await api.browse.album(albumId!, { serviceId, accountId, defaults, muse2: true, count: 50, offset }); + if (page.error) break; + const newItems = (page.data as AlbumResponse).tracks?.items ?? []; + if (!newItems.length) break; + allTracks = [...allTracks, ...newItems.map(parseTrack)]; + offset += newItems.length; + } + + return { ...parsed, tracks: allTracks }; + }, + staleTime: Infinity, + gcTime: 60 * 60 * 1000, + }; +} + +export function useAlbumBrowse( + albumId: string | undefined, + serviceId: string | undefined, + accountId: string | undefined, + defaults: string | undefined, +) { + return useQuery({ + ...albumQueryOptions(albumId, serviceId, accountId, defaults), + enabled: !!(albumId && serviceId && accountId), + }); +} diff --git a/apps/mobile/src/logic/hooks/useArtistBrowse.ts b/apps/mobile/src/logic/hooks/useArtistBrowse.ts new file mode 100644 index 0000000..0a2acae --- /dev/null +++ b/apps/mobile/src/logic/hooks/useArtistBrowse.ts @@ -0,0 +1,140 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/sonosApi'; +import { isArtist, parseServiceSearch } from '../lib/itemHelpers'; +import type { AlbumTrack } from './useAlbumBrowse'; +import { parsePlaylistTracks } from './usePlaylistBrowse'; +import type { ServiceSearch } from '../types/ServiceSearch'; +import type { SonosItem, SonosItemId } from '../types/sonos'; +import { ArtistResponse } from '../types/ArtistResponse'; + +export interface ArtistData { + name: string; + imageUrl: string | null; + albums: SonosItem[]; + playlists: SonosItem[]; // Artist Shuffle, Artist Radio (no Top Songs) + topSongs: AlbumTrack[]; + genius: GeniusArtistInfo | null; +} + +function parseArtist(data: ArtistResponse): { parsed: Omit; topSongsItem: SonosItem | null } { + const name = data.title ?? ''; + const imageUrl = data.images?.tile1x1 ?? null; + const allItems = (data.sections?.items?.[0]?.items ?? []) as unknown as SonosItem[]; + + const topSongsItem = + allItems.find((i) => i.type === 'ITEM_PLAYLIST' && (i.title as string)?.toLowerCase().includes('top songs')) ?? + null; + + const albums = allItems.filter((i) => i.type === 'ITEM_ALBUM'); + const playlists = allItems.filter((i) => i.type === 'ITEM_PLAYLIST' && i !== topSongsItem); + + return { parsed: { name, imageUrl, albums, playlists }, topSongsItem }; +} + +export function artistQueryOptions( + artistId: string | undefined, + serviceId: string | undefined, + accountId: string | undefined, + defaults: string | undefined, + fallbackName?: string, +) { + return { + queryKey: ['artist', artistId] as const, + queryFn: async (): Promise => { + // First attempt: direct browse with whatever defaults we already have. + let r = await api.browse.artist(artistId!, { serviceId, accountId, defaults, muse2: true }); + + // YT Music (and some other services) need an opaque `defaults` blob to + // return a populated artist response. Navigation sources like the + // leaderboard don't capture defaults, so the direct browse comes back + // with no title/sections. Resolve via search to get a properly-defaulted + // item, then retry the browse. + const looksEmpty = !!r.error || !(r.data as ArtistResponse | undefined)?.title; + if (looksEmpty && fallbackName) { + const sResp = await api.search.serviceQuery(fallbackName, { count: 20 }); + if (!sResp.error && sResp.data) { + const items = parseServiceSearch(sResp.data as ServiceSearch); + const artists = items.filter(isArtist); + const matched = + artists.find((a) => { + const rid = a.resource?.id as SonosItemId | undefined; + return rid?.objectId === artistId; + }) ?? artists[0]; + if (matched) { + const mid = matched.resource?.id as SonosItemId | undefined; + const mDefaults = matched.resource?.defaults as string | undefined; + r = await api.browse.artist(mid?.objectId ?? artistId!, { + serviceId: mid?.serviceId ?? serviceId, + accountId: mid?.accountId ?? accountId, + defaults: mDefaults ?? defaults, + muse2: true, + }); + } + } + } + if (r.error) throw new Error(r.error); + + const { parsed, topSongsItem } = parseArtist(r.data as ArtistResponse); + + const fetchTopSongs = async (): Promise => { + if (!topSongsItem) return []; + const rid = topSongsItem.resource?.id as SonosItemId | undefined; + if (!rid?.objectId || !rid?.serviceId || !rid?.accountId) return []; + const pr = await api.browse.playlist(rid.objectId, { + serviceId: rid.serviceId, + accountId: rid.accountId, + defaults: topSongsItem.resource?.defaults as string | undefined, + muse2: true, + }); + return pr.error ? [] : parsePlaylistTracks(pr.data); + }; + + const [topSongs, genius] = await Promise.all([ + fetchTopSongs(), + window.sonos.geniusArtist(parsed.name), + ]); + + return { ...parsed, topSongs, genius }; + }, + staleTime: Infinity, + gcTime: 60 * 60 * 1000, + }; +} + +export function useArtistBrowse( + artistId: string | undefined, + serviceId: string | undefined, + accountId: string | undefined, + defaults?: string, + fallbackName?: string, +) { + return useQuery({ + ...artistQueryOptions(artistId, serviceId, accountId, defaults, fallbackName), + enabled: !!(artistId && serviceId && accountId), + }); +} + +/** + * Lightweight fetch for just the artist's image — used by avatar UI where the + * full artistQueryOptions (top songs + Genius bio) would be wasteful. Cached + * under a distinct key so it doesn't conflict with the full browse cache. + */ +export function useArtistImage( + artistId: string | undefined, + serviceId: string | undefined, + accountId: string | undefined, + defaults?: string, +) { + return useQuery({ + queryKey: ['artist-image', artistId] as const, + queryFn: async (): Promise => { + const r = await api.browse.artist(artistId!, { serviceId, accountId, defaults, muse2: true }); + if (r.error || !r.data) return null; + return (r.data as ArtistResponse).images?.tile1x1 ?? null; + }, + enabled: !!(artistId && serviceId && accountId), + staleTime: Infinity, + gcTime: 60 * 60 * 1000, + retry: false, + }); +} diff --git a/apps/mobile/src/logic/hooks/useAttribution.ts b/apps/mobile/src/logic/hooks/useAttribution.ts new file mode 100644 index 0000000000000000000000000000000000000000..e33a57e070356747e261f5f5d0b93f927c654cd7 GIT binary patch literal 3050 zcmai0OK;mo5YAb@VuA)R<;wDQrmz8yamQ;H_&2vt}NIqV29{*ki6_{3(J6qC@ zW0i)TgTY{bpH8d^Y{K3>FQ{^y>qpA1qcCF%Iu!h+azXn*xhjgOO7BUVj338zjevqx zrp+BqZq7a3Ggp#OBUWzV2X>U&qR=sBuEUHJ;p&2dlSFpD4EZ6ikFV*=JI_1 z>T`(SNuR_9c6SG~OIM7zOt}TBSV-R) z1xuw0k~RdVr*+WQ$U{Xx)m$?JE0P$t7+w1`JdhlwhtbqV0io~;9JFEIQpjz^*- z+d4IA)YZU%=H|17L@c@qnv4o-+N(TeIwNBPef;GQ(%xg3Uwr))u%ZTw)B-wbBJzih zC*K0v$RQw5)vmIhp96>Zde0g(FR0CJU1U^hzt7x65fJrKqnCBQL*uI3#bC( zwCO0)OlplYKp8XisG&r9 zuX2_;CCUDPAEVsz+N67Rr1ku@?asV{}3-8wG;XD`S6oCS`G*r z5(yZj0rC5sbaX^JJHs*ZmI?mYb312uqC6+VgY_{?UQV?D{Lc}cQ}Q%k^x*?N#hD>! zDdUPoOVak=_s^Ewl3YDo%DL6Y-~N2I#KG13gTV^^(#Z3*(k9>+9Hc@&FnCpoNaB2M zoTiVz{!UBC){Vf5l6_ERH3Gl>T8R!JG)PpY2P_1`vx^h47Xwm9s}as0|6Bvbi+Xxh zPig9Ob_YxwIh42>-eyzKpkwK|l+osh{K+ycNW$e7jm52{tpow_h)C1KbCk|MB0eO) z?J)Bh$lipMKiq|oHz=H=DR%XhDCCMVZtZzk`sPhyy7>_e!(&e2AANOB#>I^6yAK^rc`-anjVyYpbtQ@vQ&sEJh?rG^VY#nk9ha z_>OZJiPe+_Jlyj}=41IhYD=cxNW0g)v-UCa-9Z-AI;MNj=V?|)7ts9IyYJE*EX}gYn#*SjXP>*($w2xA|n&3 zruReG;bXW>??sI9vaXZ$j+21yVIxX8{%?Xg8aG>a1Ne4Vwo+?n-toJOhN6V=4elLv T { + const unsubReady = window.sonos.onAuthReady(() => setIsAuthed(true)); + const unsubExpired = window.sonos.onAuthExpired(() => setIsAuthed(false)); + return () => { unsubReady(); unsubExpired(); }; + }, []); + + return isAuthed; +} diff --git a/apps/mobile/src/logic/hooks/useAutoplay.ts b/apps/mobile/src/logic/hooks/useAutoplay.ts new file mode 100644 index 0000000..82826fe --- /dev/null +++ b/apps/mobile/src/logic/hooks/useAutoplay.ts @@ -0,0 +1,244 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { NormalizedQueueItem } from '../types/provider'; + +/** How often each client checks in with the coordinator. */ +const POLL_MS = 10_000; + +/** Preview size — mirrors the coordinator's own UPCOMING_SIZE. */ +const UPCOMING_SIZE = 3; + +/** + * Must match the server's `trackKey()` exactly. + * + * Sonos can hand a queued track back under a different objectId than the one we + * added (issue #84), so the coordinator needs a name-based way to recognise its + * own filler — otherwise it decides the add failed and queues the same pick + * again and again. + */ +function trackKey(trackName?: string | null, artist?: string | null): string { + return `${(trackName ?? '').trim().toLowerCase()}||${(artist ?? '').trim().toLowerCase()}`; +} + +/** + * Stable per-install id. The coordinator hands its enqueue lease to one client, + * so the id has to survive reloads — a fresh id each mount would look like a new + * client every time and let the lease bounce around. + */ +export interface ClientIdStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +// RN has no localStorage: the app registers a synchronous store (e.g. an MMKV or +// SecureStore-backed cache) via setClientIdStorage. Falls back to localStorage when +// present (tests/web), else a per-session id. +let storageOverride: ClientIdStorage | null = null; +export function setClientIdStorage(s: ClientIdStorage | null): void { + storageOverride = s; +} + +function newId(): string { + const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto; + if (c?.randomUUID) return c.randomUUID(); + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => { + const r = (Math.random() * 16) | 0; + return (ch === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +function clientId(): string { + const KEY = 'truetunes.clientId'; + try { + const store: ClientIdStorage | undefined = + storageOverride ?? (globalThis as { localStorage?: ClientIdStorage }).localStorage; + if (!store) return newId(); + const existing = store.getItem(KEY); + if (existing) return existing; + const id = newId(); + store.setItem(KEY, id); + return id; + } catch { + // Storage blocked: a per-session id still works, it just + // won't be recognised as the same client after a reload. + return newId(); + } +} + +export interface AutoplayState { + enabled: boolean; + upcoming: DjTrack[]; + /** Track parked at the tail; user picks are inserted in front of it. */ + fillerUri: string | null; + setEnabled: (on: boolean) => void; +} + +interface Options { + groupId: string | null; + items: NormalizedQueueItem[]; + nowPlayingUri: string | null; + /** + * Sonos' own 1-based queue position for the playing track. Far more reliable + * than matching ids or names — the rest of the app already prefers it for the + * same reason (see DraggableQueueRow). + */ + nowPlayingQueueItemId?: string | null; + /** Whoever is in the room, so picks suit the listeners rather than the office. */ + users?: string[]; + /** Appends the coordinator's pick. Must not attribute it to anyone. */ + onEnqueue: (track: DjTrack) => Promise | void; +} + +/** + * Keeps this client in step with the autoplay coordinator. + * + * Every client polls and renders the same `upcoming`; only the one holding the + * lease is ever told to enqueue, which is what stops four clients appending four + * copies of the same filler. + */ +export function useAutoplay({ + groupId, + items, + nowPlayingUri, + nowPlayingQueueItemId, + users, + onEnqueue, +}: Options): AutoplayState { + const [state, setState] = useState<{ enabled: boolean; upcoming: DjTrack[]; fillerUri: string | null }>({ + enabled: false, + upcoming: [], + fillerUri: null, + }); + + // Read through refs inside the interval so changing queue contents don't + // restart the timer — the poll always sees current values. + const itemsRef = useRef(items); + const nowPlayingRef = useRef(nowPlayingUri); + const nowPlayingTitleRef = useRef(null); + const nowPlayingIndexRef = useRef(null); + const usersRef = useRef(users); + const onEnqueueRef = useRef(onEnqueue); + useEffect(() => { + itemsRef.current = items; + nowPlayingRef.current = nowPlayingUri; + // Resolve the playing track's name identity from the queue, so the + // coordinator can place the playhead even when the id has been re-keyed. + const playing = items.find((i) => i.track.id === nowPlayingUri); + nowPlayingTitleRef.current = playing + ? trackKey(playing.track.title, playing.track.artist) + : null; + // Sonos reports a 1-based queue position; the coordinator works in indexes. + const reported = Number(nowPlayingQueueItemId); + nowPlayingIndexRef.current = + nowPlayingQueueItemId && Number.isFinite(reported) ? reported - 1 : null; + usersRef.current = users; + onEnqueueRef.current = onEnqueue; + }); + + const pendingEnable = useRef(undefined); + /** Guards against a slow add overlapping the next poll and double-queueing. */ + const enqueuing = useRef(false); + + /** + * Preview of last resort, straight from the DJ endpoint. Used when the + * coordinator is unreachable or has nothing parked, so the section always + * answers "what's next if nobody queues anything". + */ + const fillPreviewDirectly = useCallback(async () => { + const set = await window.sonos + .fetchDjSet({ + limit: UPCOMING_SIZE, + excludeUris: itemsRef.current.map((i) => i.track.id).filter(Boolean) as string[], + ...(usersRef.current?.length ? { users: usersRef.current } : {}), + }) + .catch(() => null); + + const tracks = set?.tracks ?? []; + if (tracks.length === 0) return; + // Never overwrite a coordinator-managed list — that one reflects what's + // actually parked in the queue, this is only a stand-in. + setState((prev) => (prev.upcoming.length > 0 ? prev : { ...prev, upcoming: tracks })); + }, []); + + const poll = useCallback(async () => { + // Coordination needs a group — the session is keyed by it — but the preview + // doesn't. Returning early here left the section empty on every client that + // hadn't picked a group yet, which is exactly when you most want to see what + // the DJ would put on. + if (!groupId) { + await fillPreviewDirectly(); + return; + } + + const setEnabled = pendingEnable.current; + pendingEnable.current = undefined; + + const result = await window.sonos + .djAutoplay({ + groupId, + clientId: clientId(), + // Index-aligned with queueKeys — the coordinator compares positions. + queueUris: itemsRef.current.map((i) => i.track.id ?? ''), + queueKeys: itemsRef.current.map((i) => trackKey(i.track.title, i.track.artist)), + nowPlayingUri: nowPlayingRef.current, + nowPlayingKey: nowPlayingTitleRef.current, + nowPlayingIndex: nowPlayingIndexRef.current, + ...(usersRef.current?.length ? { users: usersRef.current } : {}), + ...(setEnabled !== undefined ? { setEnabled } : {}), + }) + .catch(() => null); + + if (!result || result.error) { + // Coordinator unreachable. Keep whatever we last showed, and if we've + // never had picks, fall back to asking the DJ directly — the preview is + // "what would play next", which doesn't depend on coordination. + await fillPreviewDirectly(); + return; + } + + const upcoming = result.upcoming ?? []; + setState({ + enabled: !!result.enabled, + upcoming, + fillerUri: result.fillerUri ?? null, + }); + + // The coordinator answered but had nothing queued up — still show the room + // what the DJ would pick, rather than an apologetic placeholder. + if (upcoming.length === 0) await fillPreviewDirectly(); + + if (result.leaseHeld && result.enqueue && !enqueuing.current) { + enqueuing.current = true; + try { + await onEnqueueRef.current(result.enqueue); + } finally { + enqueuing.current = false; + } + } + }, [groupId, fillPreviewDirectly]); + + // Switching group means a different queue with its own session. Clearing + // first stops the previous room's picks — and worse, its fillerUri, which + // decides where user tracks get inserted — leaking into the new one for the + // second or two before the first poll lands. + useEffect(() => { + setState({ enabled: false, upcoming: [], fillerUri: null }); + }, [groupId]); + + useEffect(() => { + void poll(); + const timer = setInterval(() => void poll(), POLL_MS); + return () => clearInterval(timer); + }, [poll]); + + const setEnabled = useCallback( + (on: boolean) => { + // Optimistic so the toggle feels immediate; the next poll confirms it. + setState((s) => ({ ...s, enabled: on })); + pendingEnable.current = on; + void poll(); + }, + [poll], + ); + + return { ...state, setEnabled }; +} diff --git a/apps/mobile/src/logic/hooks/useContainerBrowse.ts b/apps/mobile/src/logic/hooks/useContainerBrowse.ts new file mode 100644 index 0000000..46dd983 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useContainerBrowse.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/sonosApi'; +import { extractItems } from '../lib/itemHelpers'; +import type { SonosItem } from '../types/sonos'; + +export function useContainerBrowse( + containerId: string | undefined, + serviceId: string | undefined, + accountId: string | undefined, + defaults?: string, +) { + return useQuery({ + queryKey: ['container', containerId] as const, + queryFn: async () => { + const r = await api.browse.container(containerId!, { serviceId, accountId, defaults, muse2: true }); + if (r.error) throw new Error(r.error); + return extractItems(r.data); + }, + enabled: !!(containerId && serviceId && accountId), + staleTime: 5 * 60 * 1000, + gcTime: 60 * 60 * 1000, + }); +} diff --git a/apps/mobile/src/logic/hooks/useDailyGame.ts b/apps/mobile/src/logic/hooks/useDailyGame.ts new file mode 100644 index 0000000..7ab4c52 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useDailyGame.ts @@ -0,0 +1,262 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +export interface GameRanking { + userName: string; + gamesPlayed: number; + averageTotal: number; + averageMain: number; + averageBonus: number; + averagePercent: number; + totalScore: number; + possibleScore: number; + bestTotal: number; + tierKey: GameRankTierKey; + tierName: string; + isProvisional: boolean; +} + +// 0-100% mapped to a 0-3000 SR-like rating, used by the post-game rank-change screen. +export function computeQueuedleRating(averagePercent: number): number { + if (!Number.isFinite(averagePercent)) return 0; + return Math.round(Math.max(0, Math.min(100, averagePercent)) * 30); +} + +interface RankingAccumulator { + userName: string; + gamesPlayed: number; + totalScore: number; + possibleScore: number; + mainScore: number; + bonusScore: number; + bestTotal: number; +} + +export type GameRankTierKey = + | 'provisional' + | 'skip-button-survivor' + | 'background-bopper' + | 'aux-cable-apprentice' + | 'algorithm-whisperer' + | 'playlist-prophet'; + +interface GameRankTier { + key: GameRankTierKey; + name: string; +} + +interface GameRankingSource { + leaderboard: unknown; + maxTotal: number | null; +} + +const MIN_TIER_GAMES = 3; + +const PROVISIONAL_TIER: GameRankTier = { + key: 'provisional', + name: 'Provisional', +}; + +export function getGameRankTier( + averagePercent: number, + gamesPlayed: number +): GameRankTier & { isProvisional: boolean } { + if (gamesPlayed < MIN_TIER_GAMES) { + return { ...PROVISIONAL_TIER, isProvisional: true }; + } + if (averagePercent >= 85) return { key: 'playlist-prophet', name: 'Playlist Prophet', isProvisional: false }; + if (averagePercent >= 70) return { key: 'algorithm-whisperer', name: 'Algorithm Whisperer', isProvisional: false }; + if (averagePercent >= 55) return { key: 'aux-cable-apprentice', name: 'Aux Cable Apprentice', isProvisional: false }; + if (averagePercent >= 40) return { key: 'background-bopper', name: 'Background Bopper', isProvisional: false }; + return { key: 'skip-button-survivor', name: 'Skip Button Survivor', isProvisional: false }; +} + +function isReadyGameDoc(value: unknown): value is GameDoc { + return !!value && typeof value === 'object' && 'questions' in value && Array.isArray((value as GameDoc).questions); +} + +function isScoreRow(value: unknown): value is Pick { + if (!value || typeof value !== 'object') return false; + const row = value as Partial; + return ( + typeof row.userName === 'string' && + row.userName.trim().length > 0 && + typeof row.mainScore === 'number' && + typeof row.bonusScore === 'number' && + typeof row.total === 'number' + ); +} + +export function calculateGameRankings(sources: GameRankingSource[]): GameRanking[] { + const byUser = new Map(); + + for (const source of sources) { + const { leaderboard, maxTotal } = source; + if (typeof maxTotal !== 'number' || maxTotal <= 0) continue; + if (!leaderboard || typeof leaderboard !== 'object') continue; + const scores = (leaderboard as Partial).scores; + if (!Array.isArray(scores)) continue; + + for (const score of scores) { + if (!isScoreRow(score)) continue; + const userName = score.userName.trim(); + const existing = + byUser.get(userName) ?? + ({ + userName, + gamesPlayed: 0, + totalScore: 0, + possibleScore: 0, + mainScore: 0, + bonusScore: 0, + bestTotal: Number.NEGATIVE_INFINITY, + } satisfies RankingAccumulator); + + existing.gamesPlayed += 1; + existing.totalScore += score.total; + existing.possibleScore += maxTotal; + existing.mainScore += score.mainScore; + existing.bonusScore += score.bonusScore; + existing.bestTotal = Math.max(existing.bestTotal, score.total); + byUser.set(userName, existing); + } + } + + return [...byUser.values()] + .map((entry) => { + const averagePercent = (entry.totalScore / entry.possibleScore) * 100; + const tier = getGameRankTier(averagePercent, entry.gamesPlayed); + return { + userName: entry.userName, + gamesPlayed: entry.gamesPlayed, + averageTotal: entry.totalScore / entry.gamesPlayed, + averageMain: entry.mainScore / entry.gamesPlayed, + averageBonus: entry.bonusScore / entry.gamesPlayed, + averagePercent, + totalScore: entry.totalScore, + possibleScore: entry.possibleScore, + bestTotal: entry.bestTotal, + tierKey: tier.key, + tierName: tier.name, + isProvisional: tier.isProvisional, + }; + }) + .sort( + (a, b) => + b.averageTotal - a.averageTotal || + b.gamesPlayed - a.gamesPlayed || + b.bestTotal - a.bestTotal || + a.userName.localeCompare(b.userName) + ); +} + +export function dailyGameQueryOptions(date?: string) { + return { + queryKey: ['queuedle', date ?? 'today'] as const, + queryFn: async (): Promise => window.sonos.fetchDailyGame(date), + staleTime: 60_000, + retry: (failureCount: number, err: unknown) => { + void err; + return failureCount < 3; + }, + refetchInterval: (q: { state: { data?: GameFetchResult } }) => { + const d = q.state.data; + if (d && 'status' in d && d.status === 'pending') return 30_000; + return false as const; + }, + }; +} + +export function useDailyGame(date?: string) { + return useQuery(dailyGameQueryOptions(date)); +} + +export function useGameLeaderboard(date?: string) { + return useQuery({ + queryKey: ['queuedle-leaderboard', date ?? 'today'], + queryFn: () => window.sonos.fetchGameLeaderboard(date), + staleTime: 30_000, + refetchInterval: 60_000, + }); +} + +export function useGameDates(userName: string | null | undefined) { + return useQuery({ + queryKey: ['queuedle-dates', userName ?? ''], + queryFn: () => window.sonos.fetchGameDates(userName ?? ''), + enabled: userName !== undefined, + staleTime: 60_000, + refetchInterval: 5 * 60_000, + }); +} + +export function useGameRankings(userName: string | null | undefined, enabled: boolean) { + return useQuery({ + queryKey: ['queuedle-rankings', userName ?? ''], + queryFn: async () => { + const datesResult = await window.sonos.fetchGameDates(userName ?? ''); + const dates = Array.isArray(datesResult.dates) ? datesResult.dates.filter((date) => date.status === 'ready') : []; + const sources: Array = await Promise.all( + dates.map(async (date) => { + try { + const [leaderboard, game] = await Promise.all([ + window.sonos.fetchGameLeaderboard(date.gameId), + window.sonos.fetchDailyGame(date.gameId), + ]); + if (!isReadyGameDoc(game)) return null; + return { + leaderboard, + maxTotal: game.questions.length * 2, + } satisfies GameRankingSource; + } catch { + return null; + } + }) + ); + return calculateGameRankings(sources.filter((source): source is GameRankingSource => source !== null)); + }, + enabled: enabled && userName !== undefined, + staleTime: 5 * 60_000, + refetchInterval: 5 * 60_000, + }); +} + +export function useGameStats(gameId: string | null) { + return useQuery({ + queryKey: ['queuedle-stats', gameId ?? 'today'], + queryFn: () => window.sonos.fetchGameStats(gameId ?? undefined), + enabled: !!gameId, + staleTime: 60_000, + refetchInterval: 60_000, + }); +} + +export function useMyScore(gameId: string | null, userName: string | null | undefined) { + return useQuery({ + queryKey: ['queuedle-my-score', gameId ?? '', userName ?? ''], + queryFn: () => window.sonos.fetchMyScore(gameId!, userName!), + enabled: !!gameId && !!userName, + staleTime: Infinity, + }); +} + +export function useSubmitGameScore() { + const qc = useQueryClient(); + return useMutation< + GameSubmitResult, + Error, + { + gameId: string; + userName: string; + guesses: { main: Array<'left' | 'right'>; bonus: string[] }; + } + >({ + mutationFn: (input) => window.sonos.submitGameScore(input), + onSuccess: (_data, variables) => { + qc.invalidateQueries({ queryKey: ['queuedle-leaderboard'] }); + qc.invalidateQueries({ queryKey: ['queuedle', variables.gameId] }); + qc.invalidateQueries({ queryKey: ['queuedle', 'today'] }); + qc.invalidateQueries({ queryKey: ['queuedle-dates'] }); + qc.invalidateQueries({ queryKey: ['queuedle-rankings'] }); + }, + }); +} diff --git a/apps/mobile/src/logic/hooks/useDjDiscoveries.ts b/apps/mobile/src/logic/hooks/useDjDiscoveries.ts new file mode 100644 index 0000000..3a231d4 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useDjDiscoveries.ts @@ -0,0 +1,81 @@ +import { useQueries } from '@tanstack/react-query'; +import { artistQueryOptions } from './useArtistBrowse'; +import type { AlbumTrack } from './useAlbumBrowse'; + +export interface DjDiscovery { + track: AlbumTrack; + artist: string; + because: string; + score: number; +} + +/** + * Caps on what comes back. Each act's "Top Songs" browse returns dozens, and a + * dozen acts unbounded produced ~800 rows — a wall nobody reads, and a wall + * that made the good suggestions at the top indistinguishable from the tail. + */ +const MAX_DISCOVERIES = 30; +const MAX_PER_ARTIST = 3; + +/** Same key shape the server builds `heardTrackKeys` from. */ +function trackKey(title: string, artist: string): string { + return `${title}||${artist}`; +} + +/** + * Music by the office's favourite acts that the office has never actually played. + * + * The event corpus can't supply this. One speaker means everything in it has + * already been heard by whoever was in the room, so "new" has to come from the + * music service itself: browse each ranked act, then subtract what the history + * already contains. + * + * Runs one browse per act, which is why the server caps how many it returns. + * They're ordinary React Query calls, so the cache is shared with the artist + * pages and a second visit costs nothing. + */ +export function useDjDiscoveries(artists: DjArtist[] | undefined, enabled = true) { + const acts = (artists ?? []).filter((a) => a.artistId && a.serviceId && a.accountId); + + const results = useQueries({ + queries: acts.map((act) => ({ + ...artistQueryOptions(act.artistId, act.serviceId, act.accountId, undefined, act.artist), + enabled, + staleTime: 30 * 60_000, + })), + }); + + const isLoading = enabled && results.some((r) => r.isLoading); + + // Interleave: one unheard track per act in rank order, then go round again. + // Taking each act's tracks consecutively would clump the set by artist. + const pools = acts.map((act, i) => { + const heard = new Set(act.heardTrackKeys); + const topSongs = results[i]?.data?.topSongs ?? []; + return { + act, + tracks: topSongs.filter((t) => { + const credited = t.artists?.[0] ?? act.artist; + return !heard.has(trackKey(t.title, credited)) && !heard.has(trackKey(t.title, act.artist)); + }), + }; + }); + + const discoveries: DjDiscovery[] = []; + for (let round = 0; round < MAX_PER_ARTIST; round++) { + for (const pool of pools) { + if (discoveries.length >= MAX_DISCOVERIES) break; + const track = pool.tracks[round]; + if (!track) continue; + discoveries.push({ + track, + artist: pool.act.artist, + because: `${pool.act.because} — never played here`, + score: pool.act.score, + }); + } + if (discoveries.length >= MAX_DISCOVERIES) break; + } + + return { discoveries, isLoading }; +} diff --git a/apps/mobile/src/logic/hooks/useDjSet.ts b/apps/mobile/src/logic/hooks/useDjSet.ts new file mode 100644 index 0000000..ba2a73b --- /dev/null +++ b/apps/mobile/src/logic/hooks/useDjSet.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; + +interface DjSetOptions { + users?: string[]; + limit?: number; + excludeUris?: string[]; +} + +/** + * A failed fetch comes back as `{ error }` with no arrays on it. Filling them in + * here means no component has to guard every field — reaching straight for + * `data.listeners.includes(...)` on an error response was a crash. + */ +function normalise(res: DjSetResponse | null | undefined): DjSetResult { + return { + tracks: res?.tracks ?? [], + artists: res?.artists ?? [], + listeners: res?.listeners ?? [], + artistsConsidered: res?.artistsConsidered ?? 0, + inferredListeners: res?.inferredListeners ?? true, + error: res?.error, + }; +} + +export function djSetQueryOptions(opts: DjSetOptions = {}) { + return { + queryKey: ['dj', opts.users ?? null, opts.limit ?? null, opts.excludeUris?.length ?? 0], + queryFn: async (): Promise => normalise(await window.sonos.fetchDjSet(opts)), + // The set is built from 90 days of history — it doesn't shift minute to minute, + // and refetching on every focus would reshuffle the list under the user's cursor. + staleTime: 5 * 60_000, + retry: 1, + }; +} + +export function useDjSet(opts: DjSetOptions = {}, enabled = true) { + return useQuery({ ...djSetQueryOptions(opts), enabled }); +} diff --git a/apps/mobile/src/logic/hooks/useDominantColor.ts b/apps/mobile/src/logic/hooks/useDominantColor.ts new file mode 100644 index 0000000..68260ac --- /dev/null +++ b/apps/mobile/src/logic/hooks/useDominantColor.ts @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; + +/** Returns RGBA pixels (row-major, 4 bytes each) of a small downscaled copy of the image, or null. */ +export type PixelSampler = (src: string) => Promise; + +let sampler: PixelSampler = async () => null; + +/** Returns "r, g, b" directly (e.g. from a native palette library), or null. */ +export type ColorExtractor = (uri: string) => Promise; + +let extractor: ColorExtractor | null = null; + +/** Takes precedence over the pixel sampler when set. Pass null to unset. */ +export function setColorExtractor(fn: ColorExtractor | null): void { + extractor = fn; +} + +/** The UI layer registers a native sampler (e.g. via a downscaled bitmap) here. */ +export function setPixelSampler(fn: PixelSampler): void { + sampler = fn; +} + +/** Scores pixels and returns the most vibrant as "r, g, b". Ported verbatim from desktop. */ +export function pickDominantColor(data: Uint8ClampedArray | number[]): string { + // Score every pixel + const candidates: { r: number; g: number; b: number; score: number; sat: number; lum: number }[] = []; + for (let i = 0; i < data.length; i += 4) { + const r = data[i] / 255, g = data[i + 1] / 255, b = data[i + 2] / 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const sat = max === 0 ? 0 : (max - min) / max; + const lum = (max + min) / 2; + const score = sat * (1 - Math.abs(lum - 0.45) * 1.8); + candidates.push({ r: data[i], g: data[i + 1], b: data[i + 2], score, sat, lum }); + } + + // Sort best-first, then pick the first truly vibrant pixel. + // Falls back to the raw top scorer if the whole image is dark/grey. + candidates.sort((a, b) => b.score - a.score); + const vibrant = candidates.find(c => c.sat > 0.2 && c.lum > 0.18 && c.lum < 0.85); + const pick = vibrant ?? candidates[0]; + + return `${pick.r}, ${pick.g}, ${pick.b}`; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function useDominantColor(src: string | null, { setGlobal = false } = {}): string | null { + const [color, setColor] = useState(null); + + useEffect(() => { + if (!src) { setColor(null); return; } + setColor(null); // clear stale color immediately while new image is analysed + let cancelled = false; + if (extractor) { + extractor(src) + .then((c) => { if (!cancelled && c) setColor(c); }) + .catch(() => {}); + return () => { cancelled = true; }; + } + sampler(src) + .then((data) => { + if (cancelled || !data || data.length < 4) return; + setColor(pickDominantColor(data)); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [src]); + + // TODO(rn-ui): `setGlobal` used to write the --panel-color CSS variable on + // document.documentElement. The RN UI layer should read the returned color + // (e.g. via context) instead. + + return color; +} diff --git a/apps/mobile/src/logic/hooks/useGeniusAlbumYear.ts b/apps/mobile/src/logic/hooks/useGeniusAlbumYear.ts new file mode 100644 index 0000000..e0d7cbd --- /dev/null +++ b/apps/mobile/src/logic/hooks/useGeniusAlbumYear.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; + +export function geniusAlbumYearQueryOptions( + albumName: string | null | undefined, + artistName: string | null | undefined, +) { + return { + queryKey: ['genius-album-year', albumName, artistName] as const, + queryFn: (): Promise => + window.sonos.geniusAlbumYear(albumName!, artistName!), + staleTime: Infinity, + retry: false, + enabled: !!albumName && !!artistName, + }; +} + +export function useGeniusAlbumYear( + albumName: string | null | undefined, + artistName: string | null | undefined, +) { + const { data } = useQuery(geniusAlbumYearQueryOptions(albumName, artistName)); + return data ?? null; +} diff --git a/apps/mobile/src/logic/hooks/useGeniusDescription.ts b/apps/mobile/src/logic/hooks/useGeniusDescription.ts new file mode 100644 index 0000000..8059576 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useGeniusDescription.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; + +export function geniusDescriptionQueryOptions( + trackName: string | null | undefined, + artistName: string | null | undefined, +) { + return { + queryKey: ['genius-description', trackName, artistName] as const, + queryFn: (): Promise => + window.sonos.geniusDescription(trackName!, artistName!), + staleTime: Infinity, + retry: false, + enabled: !!trackName && !!artistName, + }; +} + +export function useGeniusDescription( + trackName: string | null | undefined, + artistName: string | null | undefined, +) { + const { data, isLoading } = useQuery( + geniusDescriptionQueryOptions(trackName, artistName), + ); + return { description: data ?? null, isLoading }; +} diff --git a/apps/mobile/src/logic/hooks/useGroups.ts b/apps/mobile/src/logic/hooks/useGroups.ts new file mode 100644 index 0000000..189b6ce --- /dev/null +++ b/apps/mobile/src/logic/hooks/useGroups.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react'; +import { getActiveProvider } from '../providers'; +import type { NormalizedGroup } from '../types/provider'; + +export function useGroups(): NormalizedGroup[] { + const [groups, setGroups] = useState([]); + + useEffect(() => { + const unsub = getActiveProvider().subscribeGroups(setGroups); + return unsub; + }, []); + + return groups; +} diff --git a/apps/mobile/src/logic/hooks/useImage.ts b/apps/mobile/src/logic/hooks/useImage.ts new file mode 100644 index 0000000..a4e903d --- /dev/null +++ b/apps/mobile/src/logic/hooks/useImage.ts @@ -0,0 +1,4 @@ +// expo-image caches by URL, so no blob/objectURL LRU is needed in RN. +export function useImage(url: string | null | undefined): string | null { + return url || null; +} diff --git a/apps/mobile/src/logic/hooks/useLyrics.ts b/apps/mobile/src/logic/hooks/useLyrics.ts new file mode 100644 index 0000000..f502e00 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useLyrics.ts @@ -0,0 +1,61 @@ +import { useQuery } from '@tanstack/react-query'; + +export interface LyricLine { + timeMs: number; + text: string; +} + +function parseLrc(lrc: string): LyricLine[] { + return lrc.split('\n').flatMap((line) => { + const m = line.match(/^\[(\d{2}):(\d{2}(?:\.\d+)?)\](.*)/); + if (!m) return []; + const text = m[3].trim(); + if (!text) return []; + return [{ timeMs: Math.round((Number(m[1]) * 60 + Number(m[2])) * 1000), text }]; + }); +} + +type LyricsData = { + syncedLyrics: string | null; + plainLyrics: string | null; + instrumental: boolean; +} | null; + +export function lyricsQueryOptions( + trackName: string | null | undefined, + artistName: string | null | undefined, + albumName: string | null | undefined, + durationMs: number, +) { + return { + queryKey: ['lyrics', trackName, artistName, albumName] as const, + queryFn: async (): Promise => { + const p = new URLSearchParams({ track_name: trackName!, artist_name: artistName! }); + if (albumName) p.set('album_name', albumName); + if (durationMs) p.set('duration', String(Math.round(durationMs / 1000))); + const res = await fetch(`https://lrclib.net/api/get?${p}`); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`LRCLIB ${res.status}`); + return res.json(); + }, + staleTime: Infinity, + retry: false, + enabled: !!trackName && !!artistName, + }; +} + +export function useLyrics( + trackName: string | null | undefined, + artistName: string | null | undefined, + albumName: string | null | undefined, + durationMs: number, +) { + const { data, isLoading } = useQuery(lyricsQueryOptions(trackName, artistName, albumName, durationMs)); + + return { + lines: data?.syncedLyrics ? parseLrc(data.syncedLyrics) : [], + isLoading, + isInstrumental: data?.instrumental ?? false, + notFound: data === null && !isLoading, + }; +} diff --git a/apps/mobile/src/logic/hooks/useNowPlaying.ts b/apps/mobile/src/logic/hooks/useNowPlaying.ts new file mode 100644 index 0000000..a3aa05e --- /dev/null +++ b/apps/mobile/src/logic/hooks/useNowPlaying.ts @@ -0,0 +1,74 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useImage } from './useImage'; +import { useTrackDetails } from './useTrackDetails'; +import { useDominantColor } from './useDominantColor'; +import { albumQueryOptions } from './useAlbumBrowse'; +import type { PlaybackState } from './usePlayback'; +import type { SonosItem } from '../types/sonos'; + +export function useNowPlaying(playback: PlaybackState) { + const { + artUrl, trackName, artistName, timeLabel, + progressPct, durationMs, isPlaying, isVisible, + shuffle, repeat, volume, isExplicit, + currentObjectId, currentServiceId, currentAccountId, + currentAlbumId, currentAlbumName, + } = playback; + + const { data: td } = useTrackDetails( + currentObjectId ?? undefined, + currentServiceId ?? undefined, + currentAccountId ?? undefined, + ); + + const cachedArt = useImage(td?.artUrl ?? artUrl); + const dominantColor = useDominantColor(cachedArt); + + const displayTrack = td?.trackName ?? trackName; + const displayArtist = td?.artist ?? artistName; + const albumName = td?.albumName ?? currentAlbumName; + const albumId = td?.albumId ?? currentAlbumId; + const svcId = td?.serviceId ?? currentServiceId ?? undefined; + const accId = td?.accountId ?? currentAccountId ?? undefined; + + const [elapsedLabel = '', durationLabel = ''] = timeLabel ? timeLabel.split(' / ') : []; + + const queryClient = useQueryClient(); + const prefetchAlbum = () => { + if (albumId && svcId && accId) { + queryClient.prefetchQuery(albumQueryOptions(albumId, svcId, accId, undefined)); + } + }; + + const albumItem: SonosItem | null = albumId ? { + title: albumName ?? '', + type: 'ITEM_ALBUM', + resource: { type: 'ALBUM', id: { objectId: albumId, serviceId: svcId, accountId: accId } }, + } as SonosItem : null; + + const artistId = td?.artistId; + const artistItem: SonosItem | null = artistId ? { + title: displayArtist ?? '', + type: 'ARTIST', + resource: { type: 'ARTIST', id: { objectId: artistId, serviceId: svcId, accountId: accId } }, + } as SonosItem : null; + + return { + // Display values + displayTrack, displayArtist, albumName, albumId, + cachedArt, dominantColor, + elapsedLabel, durationLabel, + // Passthrough from playback state + progressPct, durationMs, isPlaying, isVisible, + shuffle, repeat, volume, isExplicit, + // Current track identity (for favouriting etc.) + currentObjectId: currentObjectId ?? null, + currentServiceId: svcId ?? null, + currentAccountId: accId ?? null, + artUrlRaw: td?.artUrl ?? artUrl ?? null, + // Derived + albumItem, + artistItem, + prefetchAlbum, + }; +} diff --git a/apps/mobile/src/logic/hooks/useOfficePresence.ts b/apps/mobile/src/logic/hooks/useOfficePresence.ts new file mode 100644 index 0000000..b6dc70f --- /dev/null +++ b/apps/mobile/src/logic/hooks/useOfficePresence.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; + +export function officePresenceQueryOptions() { + return { + queryKey: ['office-presence'], + queryFn: async (): Promise => { + const res = await window.sonos.fetchOfficePresence(); + return { + inOffice: res?.inOffice ?? [], + basis: res?.basis ?? 'none', + observed: res?.observed ?? 0, + detail: res?.detail ?? [], + error: res?.error, + }; + }, + // People wander in and out; a couple of minutes is fresh enough and keeps + // us well inside Graph's rate limit. + staleTime: 2 * 60_000, + retry: 1, + }; +} + +export function useOfficePresence(enabled = true) { + return useQuery({ ...officePresenceQueryOptions(), enabled }); +} diff --git a/apps/mobile/src/logic/hooks/useOpenItem.ts b/apps/mobile/src/logic/hooks/useOpenItem.ts new file mode 100644 index 0000000..53d8495 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useOpenItem.ts @@ -0,0 +1,20 @@ +// react-router-dom is not used in RN: the caller supplies a navigate function. +import { resolveAlbumParams, resolveArtistParams, isArtist, isContainer } from '../lib/itemHelpers'; +import type { SonosItem } from '../types/sonos'; + +export type NavigateFn = (to: string, options?: { state?: unknown }) => void; + +export function useOpenItem(navigate: NavigateFn) { + return (item: SonosItem) => { + if (isArtist(item)) { + const { artistId } = resolveArtistParams(item); + navigate(`/artist/${encodeURIComponent(artistId ?? '_')}`, { state: { item } }); + } else if (isContainer(item)) { + const { albumId } = resolveAlbumParams(item); + navigate(`/container/${encodeURIComponent(albumId ?? '_')}`, { state: { item } }); + } else { + const { albumId } = resolveAlbumParams(item); + navigate(`/album/${encodeURIComponent(albumId ?? '_')}`, { state: { item } }); + } + }; +} diff --git a/apps/mobile/src/logic/hooks/usePlayback.ts b/apps/mobile/src/logic/hooks/usePlayback.ts new file mode 100644 index 0000000..9a9a2be --- /dev/null +++ b/apps/mobile/src/logic/hooks/usePlayback.ts @@ -0,0 +1,220 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getActiveProvider } from "../providers"; +import type { NormalizedPlaybackState } from "../types/provider"; +import { fmtTime } from "../lib/itemHelpers"; + +export interface PlaybackState { + isVisible: boolean; + trackName: string; + artistName: string; + artUrl: string | null; + stateIcon: string; + timeLabel: string; + progressPct: number; + positionMs: number; + durationMs: number; + isPlaying: boolean; + shuffle: boolean; + repeat: "none" | "one" | "all"; + volume: number; + currentObjectId: string | null; + currentServiceId: string | null; + currentAccountId: string | null; + currentAlbumId: string | null; + currentAlbumName: string | null; + isExplicit: boolean; + queueId: string | null; + queueVersion: string | null; + queueItemId: string | null; +} + +const IDLE_STATE: PlaybackState = { + isVisible: false, + trackName: "", + artistName: "", + artUrl: null, + stateIcon: "–", + timeLabel: "", + progressPct: 0, + positionMs: 0, + durationMs: 0, + isPlaying: false, + shuffle: false, + repeat: "none", + volume: 50, + currentObjectId: null, + currentServiceId: null, + currentAccountId: null, + currentAlbumId: null, + currentAlbumName: null, + isExplicit: false, + queueId: null, + queueVersion: null, + queueItemId: null, +}; + +export function usePlayback(activeGroupId: string | null) { + const [state, setState] = useState(IDLE_STATE); + + // Internal progress tracking — these don't need to trigger re-renders on every tick + const positionMsRef = useRef(0); + const durationMsRef = useRef(0); + const lastUpdateAtRef = useRef(0); + const isPlayingRef = useRef(false); + + // Always-current queue cursors — updated immediately on every provider push, + // before the !name guard and outside React batching, so callers never + // read a version that's one message behind. + const queueIdRef = useRef(null); + const queueVersionRef = useRef(null); + + // Cache normalized state per groupId so we can restore on group switch + const groupCache = useRef>(new Map()); + + // Track activeGroupId in a ref so the subscription handler (registered once) always sees the current value + const activeGroupIdRef = useRef(activeGroupId); + useEffect(() => { + activeGroupIdRef.current = activeGroupId; + }, [activeGroupId]); + + const applyState = useCallback((normalized: NormalizedPlaybackState) => { + const { status, isPlaying, positionMs, durationMs, currentTrack, + shuffle, repeatMode, queueId, queueVersion, queueItemId } = normalized; + + const pct = durationMs > 0 ? Math.min((positionMs / durationMs) * 100, 100) : 0; + + positionMsRef.current = positionMs; + durationMsRef.current = durationMs; + lastUpdateAtRef.current = Date.now(); + isPlayingRef.current = isPlaying; + + if (queueId) { + queueIdRef.current = queueId; + window.sonos.setQueueId(queueId); + } + if (queueVersion) queueVersionRef.current = queueVersion; + + if (!currentTrack?.title) { + // Still surface queue cursors into React state so useQueue can react to them + // even when nothing is playing (no track name). + if (queueId || queueVersion || queueItemId) { + setState(prev => ({ + ...prev, + ...(queueId ? { queueId } : {}), + ...(queueVersion ? { queueVersion } : {}), + ...(queueItemId ? { queueItemId } : {}), + })); + } + return; + } + + setState((prev) => ({ + isVisible: true, + trackName: currentTrack.title, + artistName: currentTrack.artist, + artUrl: currentTrack.imageUrl, + stateIcon: status === 'playing' ? "▶" : status === 'paused' ? "⏸" : "–", + timeLabel: durationMs > 0 ? `${fmtTime(positionMs)} / ${fmtTime(durationMs)}` : "", + progressPct: pct, + positionMs, + durationMs, + isPlaying, + shuffle, + // Playback state doesn't carry volume — keep whatever the provider last delivered. + volume: prev.volume, + repeat: repeatMode, + currentObjectId: + currentTrack.id !== prev.currentObjectId ? currentTrack.id : prev.currentObjectId, + currentServiceId: currentTrack.serviceId, + currentAccountId: currentTrack.accountId, + currentAlbumId: currentTrack.albumId, + currentAlbumName: currentTrack.albumName, + isExplicit: currentTrack.isExplicit, + queueId, + queueVersion, + queueItemId, + })); + }, []); + + // Progress tick — runs every second while playing + useEffect(() => { + if (!state.isPlaying) return; + const timer = setInterval(() => { + const elapsed = Date.now() - lastUpdateAtRef.current; + const pos = Math.min( + positionMsRef.current + elapsed, + durationMsRef.current || 1, + ); + const dur = durationMsRef.current; + const pct = dur > 0 ? (pos / dur) * 100 : 0; + setState((prev) => ({ + ...prev, + positionMs: pos, + timeLabel: dur > 0 ? `${fmtTime(pos)} / ${fmtTime(dur)}` : "", + progressPct: pct, + })); + }, 1000); + return () => clearInterval(timer); + }, [state.isPlaying]); + + // Provider subscription + useEffect(() => { + const unsubReady = window.sonos.onWsReady(() => { + setState((prev) => ({ ...prev, isVisible: true })); + }); + + const provider = getActiveProvider(); + const unsubPlayback = provider.subscribePlayback( + (msgGroupId, normalized) => { + // Cache for every group + if (msgGroupId) groupCache.current.set(msgGroupId, normalized); + + // Only apply to UI if it's for the active group (or group isn't known yet) + if ( + msgGroupId && + activeGroupIdRef.current && + msgGroupId !== activeGroupIdRef.current + ) return; + + applyState(normalized); + }, + (vol) => setState((prev) => ({ ...prev, volume: vol })), + ); + + return () => { unsubReady(); unsubPlayback(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Apply cached state when active group changes + const applyGroupCache = useCallback( + (groupId: string) => { + // Update the group ref immediately — don't wait for the useEffect. + // Without this, provider messages for the old group can still arrive and + // overwrite queueVersionRef with the old etag before the effect runs. + activeGroupIdRef.current = groupId; + // Clear queue cursors. queueVersionRef must NOT be restored from + // cache — the cached version can be stale if the queue changed since + // the last push. It will be refreshed by the next live message + // or by the API response etag on the first add. + queueIdRef.current = null; + queueVersionRef.current = null; + const cached = groupCache.current.get(groupId); + if (cached) { + // Reset currentObjectId to force downstream reload + setState((prev) => ({ ...prev, currentObjectId: null })); + applyState(cached); + // Re-null after applyState in case it repopulated from stale cache + queueVersionRef.current = null; + } else { + setState({ + ...IDLE_STATE, + isVisible: true, + trackName: "Nothing playing", + }); + } + }, + [applyState], + ); + + return { playback: state, applyGroupCache, queueIdRef, queueVersionRef }; +} diff --git a/apps/mobile/src/logic/hooks/usePlaylistBrowse.ts b/apps/mobile/src/logic/hooks/usePlaylistBrowse.ts new file mode 100644 index 0000000..4e77eec --- /dev/null +++ b/apps/mobile/src/logic/hooks/usePlaylistBrowse.ts @@ -0,0 +1,67 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/sonosApi'; +import { decodeDefaults } from '../lib/itemHelpers'; +import type { AlbumTrack } from './useAlbumBrowse'; +import type { PlaylistResponse } from '../types/PlaylistResponse'; + +function cleanArtistId(id: string): string { + return id.replace(/^srn:content:audio:artist:/, '').replace(/#.*$/, ''); +} + +function parseDuration(d: string | number | undefined): number { + if (!d) return 0; + if (typeof d === 'number') return d; + // ISO 8601: PT3M26S + const m = String(d).match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (m) return Number(m[1] || 0) * 3600 + Number(m[2] || 0) * 60 + Number(m[3] || 0); + return Number(d) || 0; +} + +export function parsePlaylistTracks(data: unknown): AlbumTrack[] { + const d = data as PlaylistResponse; + const rawTracks = d.tracks?.items ?? []; + + return rawTracks.map((t, idx) => ({ + title: t.title ?? '', + ordinal: idx + 1, + durationSeconds: parseDuration(t.duration), + artUrl: t.images?.tile1x1 ?? null, + id: t.resource?.id ?? {}, + artists: t.artists?.map((a) => a.name) ?? [], + artistObjects: t.artists?.filter((a) => a.id).map((a) => ({ name: a.name, objectId: cleanArtistId(a.id) })), + albumName: (decodeDefaults(t.resource?.defaults)?.['containerName'] as string) ?? null, + albumId: (decodeDefaults(t.resource?.defaults)?.['containerId'] as string) ?? null, + explicit: t.isExplicit ?? false, + raw: t as never, + })); +} + +export function playlistQueryOptions( + playlistId: string | undefined, + serviceId: string | undefined, + accountId: string | undefined, + defaults?: string, +) { + return { + queryKey: ['playlist', playlistId] as const, + queryFn: async () => { + const r = await api.browse.playlist(playlistId!, { serviceId, accountId, defaults, muse2: true }); + if (r.error) throw new Error(r.error); + return parsePlaylistTracks(r.data); + }, + staleTime: Infinity, + gcTime: 60 * 60 * 1000, + }; +} + +export function usePlaylistBrowse( + playlistId: string | undefined, + serviceId: string | undefined, + accountId: string | undefined, + defaults?: string +) { + return useQuery({ + ...playlistQueryOptions(playlistId, serviceId, accountId, defaults), + enabled: !!(playlistId && serviceId && accountId), + }); +} diff --git a/apps/mobile/src/logic/hooks/usePlaylists.ts b/apps/mobile/src/logic/hooks/usePlaylists.ts new file mode 100644 index 0000000..afe0ad2 --- /dev/null +++ b/apps/mobile/src/logic/hooks/usePlaylists.ts @@ -0,0 +1,134 @@ +import { useCallback } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useToast } from '../context/ToastContext'; + +export function useEnsureFavourites(userName: string | null | undefined) { + return useQuery({ + queryKey: ['favourites-ensure', userName], + queryFn: () => window.sonos.ensureFavourites(), + enabled: !!userName, + staleTime: 5 * 60_000, + gcTime: Infinity, + retry: 2, + }); +} + +export function favouritesId(userName: string) { + return `fav-${userName}`; +} + +async function safeFetchPlaylists(filter: { owner?: string; member?: string }): Promise { + const result = await window.sonos.fetchPlaylists(filter); + return Array.isArray(result) ? result : []; +} + +export function useMyPlaylists(userName: string | null | undefined) { + const owned = useQuery({ + queryKey: ['playlists', 'owned', userName], + queryFn: () => safeFetchPlaylists({ owner: userName! }), + enabled: !!userName, + staleTime: 2 * 60_000, + }); + const joined = useQuery({ + queryKey: ['playlists', 'joined', userName], + queryFn: () => safeFetchPlaylists({ member: userName! }), + enabled: !!userName, + staleTime: 2 * 60_000, + }); + return { + owned: owned.data ?? [], + joined: joined.data ?? [], + isLoading: owned.isLoading || joined.isLoading, + refetch: () => { owned.refetch(); joined.refetch(); }, + }; +} + +export function usePlaylist(id: string | undefined) { + return useQuery({ + queryKey: ['playlist', id], + queryFn: async () => { + const result = await window.sonos.fetchPlaylist(id!); + if (!result || typeof result !== 'object' || 'error' in result) throw new Error('playlist not found'); + return result as PlaylistDoc; + }, + enabled: !!id, + staleTime: 60_000, + }); +} + +interface FavTrack { + uri: string | null | undefined; + trackName: string | undefined; + artist: string | undefined; + albumName?: string | undefined; + imageUrl?: string | null; + serviceId: string | null | undefined; + accountId: string | null | undefined; +} + +export function useFavourite(displayName: string | null | undefined, track: FavTrack | null) { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const favId = displayName ? favouritesId(displayName) : null; + + const { data: playlist } = usePlaylist(favId ?? undefined); + + const isFavourited = !!(track?.uri && playlist?.tracks.some(t => t.uri === track.uri)); + + const toggle = useCallback(async () => { + if (!favId || !track?.uri || !displayName) return; + const snapshot = queryClient.getQueryData(['playlist', favId]); + if (isFavourited) { + if (snapshot) { + queryClient.setQueryData(['playlist', favId], { + ...snapshot, + tracks: snapshot.tracks.filter(t => t.uri !== track.uri), + }); + } + try { + await window.sonos.removeTrackFromPlaylist(favId, track.uri); + queryClient.invalidateQueries({ queryKey: ['playlist', favId] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + } catch { + if (snapshot) queryClient.setQueryData(['playlist', favId], snapshot); + showToast('Failed to remove from Favourites'); + } + } else { + const playlistTrack: PlaylistTrack = { + uri: track.uri, + trackName: track.trackName ?? '', + artist: track.artist ?? '', + albumName: track.albumName, + imageUrl: track.imageUrl ?? null, + serviceId: track.serviceId ?? '', + accountId: track.accountId ?? '', + addedBy: displayName, + addedAt: Date.now(), + }; + if (snapshot) { + queryClient.setQueryData(['playlist', favId], { + ...snapshot, + tracks: [...snapshot.tracks, playlistTrack], + }); + } + try { + await window.sonos.addTrackToPlaylist(favId, playlistTrack); + queryClient.invalidateQueries({ queryKey: ['playlist', favId] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + } catch { + if (snapshot) queryClient.setQueryData(['playlist', favId], snapshot); + showToast('Failed to add to Favourites'); + } + } + }, [favId, track, isFavourited, displayName, queryClient, showToast]); + + return { isFavourited, toggle }; +} + +export function useInvalidatePlaylists() { + const queryClient = useQueryClient(); + return (userName: string) => { + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', userName] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'joined', userName] }); + }; +} diff --git a/apps/mobile/src/logic/hooks/usePrefetchNextLyrics.ts b/apps/mobile/src/logic/hooks/usePrefetchNextLyrics.ts new file mode 100644 index 0000000..92e7af3 --- /dev/null +++ b/apps/mobile/src/logic/hooks/usePrefetchNextLyrics.ts @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { lyricsQueryOptions } from './useLyrics'; +import { geniusDescriptionQueryOptions } from './useGeniusDescription'; +import type { NormalizedQueueItem } from '../types/provider'; + +export function usePrefetchNextLyrics( + items: NormalizedQueueItem[], + queueItemId: string | null, +) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!queueItemId) return; + const next = items[Number(queueItemId)]; // queueItemId is 1-based, so index = id - 1 + 1 = id + if (!next) return; + const { title, artist, albumName, durationMs } = next.track; + queryClient.prefetchQuery(lyricsQueryOptions(title, artist, albumName, durationMs)); + queryClient.prefetchQuery(geniusDescriptionQueryOptions(title, artist)); + }, [queryClient, items, queueItemId]); +} diff --git a/apps/mobile/src/logic/hooks/useQueue.ts b/apps/mobile/src/logic/hooks/useQueue.ts new file mode 100644 index 0000000..416c66c --- /dev/null +++ b/apps/mobile/src/logic/hooks/useQueue.ts @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getActiveProvider } from '../providers'; +import type { NormalizedQueueItem } from '../types/provider'; + +const PAGE = 50; + +export function useQueue( + isAuthed: boolean, + activeGroupId: string | null, + queueId: string | null, + onEtag?: (etag: string) => void, + onReloadError?: (msg: string) => void, +) { + const [items, setItems] = useState([]); + const [isLoading, setLoading] = useState(false); + const [error, setError] = useState(null); + const hasLoaded = useRef(false); + const onEtagRef = useRef(onEtag); + const onReloadErrorRef = useRef(onReloadError); + useEffect(() => { onEtagRef.current = onEtag; }); + useEffect(() => { onReloadErrorRef.current = onReloadError; }); + + const load = useCallback(async () => { + if (!activeGroupId) return; + // Only show the spinner on the very first load — background reloads are silent + if (!hasLoaded.current) setLoading(true); + setError(null); + + const all: NormalizedQueueItem[] = []; + let offset = 0; + let freshEtag: string | undefined; + const provider = getActiveProvider(); + + while (true) { + const r = await provider.getQueue({ count: PAGE, offset }); + if (r.error) { + // First load: show the error in place of the queue. + // Background reload after a successful first load: keep the existing + // items visible and surface the error via the optional callback — + // a transient 5xx during queue propagation should not blank the UI. + if (!hasLoaded.current) { + setError(r.error); + } else { + onReloadErrorRef.current?.(r.error); + } + setLoading(false); + return; + } + if (offset === 0 && r.etag) freshEtag = r.etag; + all.push(...r.items); + if (r.items.length < PAGE) break; + offset += r.items.length; + } + + if (freshEtag) onEtagRef.current?.(freshEtag); + setItems(all); + hasLoaded.current = true; + setLoading(false); + }, [activeGroupId, queueId]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (isAuthed && activeGroupId) load(); + }, [isAuthed, activeGroupId, load]); + + return { items, setItems, isLoading, error, reload: load }; +} diff --git a/apps/mobile/src/logic/hooks/useQueueTrack.ts b/apps/mobile/src/logic/hooks/useQueueTrack.ts new file mode 100644 index 0000000..cac2591 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useQueueTrack.ts @@ -0,0 +1,46 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useTrackDetails } from './useTrackDetails'; +import { albumQueryOptions } from './useAlbumBrowse'; +import { artistQueryOptions } from './useArtistBrowse'; +import type { NormalizedQueueItem } from '../types/provider'; +import type { SonosItem } from '../types/sonos'; + +export function useQueueTrack(item: NormalizedQueueItem, currentObjectId: string | null) { + const queryClient = useQueryClient(); + + const { id: trackId, serviceId, accountId } = item.track; + + const { data } = useTrackDetails(trackId || undefined, serviceId ?? undefined, accountId ?? undefined); + + const isPlaying = !!currentObjectId && item.track.id === currentObjectId; + const artUrl = data?.artUrl ?? item.track.imageUrl; + const artist = data?.artist ?? item.track.artist; + const albumName = data?.albumName ?? item.track.albumName; + const albumId = data?.albumId ?? item.track.albumId; + const albumSvcId = data?.serviceId ?? serviceId; + const albumAccId = data?.accountId ?? accountId; + const explicit = item.track.isExplicit; + + const albumItem: SonosItem | null = albumId && albumName ? { + title: albumName, + type: 'ITEM_ALBUM', + resource: { type: 'ALBUM', id: { objectId: albumId, serviceId: albumSvcId ?? '', accountId: albumAccId ?? '' } }, + } as SonosItem : null; + + const prefetchAlbum = albumId && albumSvcId && albumAccId + ? () => queryClient.prefetchQuery(albumQueryOptions(albumId, albumSvcId!, albumAccId!, undefined)) + : undefined; + + const artistId = data?.artistId ?? null; + const artistItem: SonosItem | null = artistId && artist ? { + name: artist, + type: 'ARTIST', + resource: { type: 'ARTIST', id: { objectId: artistId, serviceId: serviceId ?? '', accountId: accountId ?? '' } }, + } as SonosItem : null; + + const prefetchArtist = artistId && serviceId && accountId + ? () => queryClient.prefetchQuery(artistQueryOptions(artistId, serviceId, accountId, undefined)) + : undefined; + + return { artUrl, artist, albumName, albumItem, prefetchAlbum, artistItem, prefetchArtist, isPlaying, explicit }; +} diff --git a/apps/mobile/src/logic/hooks/useRecentlyPlayed.ts b/apps/mobile/src/logic/hooks/useRecentlyPlayed.ts new file mode 100644 index 0000000..07a54d4 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useRecentlyPlayed.ts @@ -0,0 +1,91 @@ +import { useQuery, useQueries } from '@tanstack/react-query'; +import { api } from '../lib/sonosApi'; +import type { SonosItem } from '../types/sonos'; +import type { ArtistResponse } from '../types/ArtistResponse'; + +function relativeDate(ts: number): string { + const diffMs = Date.now() - ts; + const days = Math.floor(diffMs / 86_400_000); + if (days === 0) return 'Today'; + if (days === 1) return 'Yesterday'; + if (days < 7) return `${days} days ago`; + if (days < 14) return '1 week ago'; + return `${Math.floor(days / 7)} weeks ago`; +} + +function toArtistItem(a: RecentArtist, imageUrl?: string | null): SonosItem { + return { + type: 'ARTIST', + title: a.artist, + name: a.artist, + imageUrl: imageUrl ?? a.imageUrl, + resource: { + type: 'ARTIST', + id: { objectId: a.artistId, serviceId: a.serviceId, accountId: a.accountId }, + }, + }; +} + +function toAlbumItem(a: RecentAlbum): SonosItem { + return { + type: 'ITEM_ALBUM', + title: a.album, + name: a.album, + artist: a.artist, + imageUrl: a.imageUrl, + id: { objectId: a.albumId, serviceId: a.serviceId, accountId: a.accountId }, + summary: { content: `${a.artist} • ${relativeDate(a.lastPlayed)}` }, + }; +} + +export function useRecentlyPlayed(selectedUserId?: string) { + const { data: currentUserId } = useQuery({ + queryKey: ['displayName'], + queryFn: () => window.sonos.getDisplayName(), + staleTime: Infinity, + }); + + const userId = selectedUserId ?? currentUserId ?? null; + + const { data, isLoading } = useQuery({ + queryKey: ['recentlyPlayed', userId], + queryFn: async () => { + if (!userId) return null; + return window.sonos.fetchRecentlyPlayed(userId); + }, + enabled: !!userId, + staleTime: 5 * 60 * 1000, + }); + + const rawArtists = data?.artists ?? []; + + const artistImageQueries = useQueries({ + queries: rawArtists.slice(0, 9).map((a) => ({ + queryKey: ['artist-image', a.artistId], + queryFn: async (): Promise => { + if (!a.artistId || !a.serviceId || !a.accountId) return null; + const r = await api.browse.artist(a.artistId, { + serviceId: a.serviceId, + accountId: a.accountId, + muse2: true, + }); + if (r.error) return null; + return (r.data as ArtistResponse).images?.tile1x1 ?? null; + }, + staleTime: Infinity, + gcTime: 60 * 60 * 1000, + enabled: !!a.artistId && !!a.serviceId && !!a.accountId, + })), + }); + + return { + artistItems: rawArtists.slice(0, 9).map((a, i) => { + const q = artistImageQueries[i]; + return toArtistItem(a, q?.isSuccess ? (q.data ?? undefined) : undefined); + }), + albumItems: (data?.albums ?? []).map(toAlbumItem), + availableUsers: data?.availableUsers ?? [], + currentUserId: currentUserId ?? null, + isLoading: !!userId && isLoading, + }; +} diff --git a/apps/mobile/src/logic/hooks/useResolveAndOpen.ts b/apps/mobile/src/logic/hooks/useResolveAndOpen.ts new file mode 100644 index 0000000..566ff78 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useResolveAndOpen.ts @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { api } from '../lib/sonosApi'; +import { getName, isAlbum, isArtist, parseServiceSearch, splitArtists } from '../lib/itemHelpers'; +import type { ServiceSearch } from '../types/ServiceSearch'; +import type { SonosArtist, SonosItem } from '../types/sonos'; +import { useOpenItem, type NavigateFn } from './useOpenItem'; + +type Target = 'artist' | 'album'; + +interface ResolveOpts { + /** Artist hint used to disambiguate same-named albums by different artists. */ + artist?: string; + /** Scope the Sonos search to this service+account; helps when the artist lives on YT Music etc. */ + serviceId?: string; + accountId?: string; +} + +function itemArtistNames(item: SonosItem): string[] { + const arr = (item.artists ?? []) as SonosArtist[]; + return arr.map((a) => a.name?.toLowerCase() ?? '').filter(Boolean); +} + +export function useResolveAndOpen(navigate: NavigateFn) { + const openItem = useOpenItem(navigate); + const [pending, setPending] = useState(null); + + async function resolveAndOpen(query: string, target: Target, opts?: ResolveOpts) { + if (!query) return; + const key = `${target}:${query}:${opts?.artist ?? ''}`; + if (pending === key) return; + setPending(key); + try { + const searchQ = target === 'album' && opts?.artist ? `${query} ${opts.artist}` : query; + const r = await api.search.serviceQuery(searchQ, { + count: 20, + serviceId: opts?.serviceId, + accountId: opts?.accountId, + }); + if (r.error) return; + const items = parseServiceSearch(r.data as ServiceSearch); + const filter = target === 'artist' ? isArtist : isAlbum; + const candidates = items.filter(filter); + + const lowerName = query.toLowerCase(); + // The hint may be a joined multi-artist string ("Sonny Stitt, Kenny Garrett"); + // candidates list artists individually, so match against each split name. + const hintArtists = opts?.artist ? splitArtists(opts.artist.toLowerCase()) : []; + const nameMatches = candidates.filter((c) => getName(c).toLowerCase() === lowerName); + + // For albums with an artist hint, require the artist to match — otherwise we'd + // happily land on a same-named album/single by a different artist. + let match: SonosItem | undefined; + if (target === 'album' && hintArtists.length > 0) { + const matchesHint = (c: SonosItem) => + itemArtistNames(c).some((n) => hintArtists.includes(n)); + match = nameMatches.find(matchesHint) ?? candidates.find(matchesHint); + } else if (target === 'artist' && lowerName) { + match = nameMatches[0] ?? candidates[0]; + } else { + match = nameMatches[0] ?? candidates[0]; + } + + if (match) openItem(match); + } finally { + setPending(null); + } + } + + return { resolveAndOpen, pending }; +} diff --git a/apps/mobile/src/logic/hooks/useSkinnyMode.ts b/apps/mobile/src/logic/hooks/useSkinnyMode.ts new file mode 100644 index 0000000..f420e7b --- /dev/null +++ b/apps/mobile/src/logic/hooks/useSkinnyMode.ts @@ -0,0 +1,24 @@ +import { useRef } from 'react'; +import { useWindowDimensions } from 'react-native'; + +// Width below which the full layout no longer fits and the app collapses +// to the skinny side-panel layout (queue + condensed transport only). +export const FULL_MIN = 864 + 280; // 1144 + +// Hysteresis: once skinny, don't pop back to full until comfortably past the +// threshold, so resizing right at the boundary doesn't flicker between layouts. +export const EXIT_BUFFER = 40; + +/** Pure threshold logic with hysteresis. */ +export function computeSkinny(width: number, prevSkinny: boolean): boolean { + return prevSkinny ? width <= FULL_MIN + EXIT_BUFFER : width < FULL_MIN; +} + +/** Returns true when the window is narrow enough to use the skinny side-panel layout. */ +export function useSkinnyMode(): boolean { + const { width } = useWindowDimensions(); + const prev = useRef(null); + const skinny = computeSkinny(width, prev.current ?? width < FULL_MIN); + prev.current = skinny; + return skinny; +} diff --git a/apps/mobile/src/logic/hooks/useStats.ts b/apps/mobile/src/logic/hooks/useStats.ts new file mode 100644 index 0000000..3a0adc8 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useStats.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; + +export type StatsPeriod = 'today' | 'week' | 'alltime'; + +export function useStats(period: StatsPeriod, userId?: string) { + return useQuery({ + queryKey: ['stats', period, userId ?? null], + queryFn: () => window.sonos.fetchStats(period, userId), + staleTime: 60_000, + retry: 1, + }); +} diff --git a/apps/mobile/src/logic/hooks/useTrackDetails.ts b/apps/mobile/src/logic/hooks/useTrackDetails.ts new file mode 100644 index 0000000..c0e7fe0 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useTrackDetails.ts @@ -0,0 +1,83 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/sonosApi'; +import { decodeDefaults } from '../lib/itemHelpers'; + +export interface TrackDetails { + trackName: string | undefined; + artUrl: string | undefined; + artist: string | undefined; + artistId: string | undefined; + albumName: string | undefined; + albumId: string | undefined; + serviceId: string | undefined; + accountId: string | undefined; + durationMs: number | undefined; +} + +function parseResponse(data: unknown): TrackDetails { + const d = data as Record; + const item = d['item'] as Record | undefined; + + // Track name + const trackName: string | undefined = (d['title'] as string | undefined) ?? (item?.['title'] as string | undefined); + + // Top-level images (highest quality, yt3.googleusercontent.com) + const images = d['images'] as Record | undefined; + const artUrl = images?.['tile1x1'] ?? (item?.['images'] as Record | undefined)?.['tile1x1']; + + // Artist from subtitle or artists array + const artist: string | undefined = + (d['subtitle'] as string | undefined) ?? + (item?.['subtitle'] as string | undefined) ?? + (item?.['artists'] as Array<{ name: string }> | undefined)?.[0]?.name; + + // Resource IDs + const resourceId = (item?.['resource'] as Record | undefined)?.['id'] as + | Record + | undefined; + const serviceId = resourceId?.['serviceId']; + const accountId = resourceId?.['accountId']; + + // Decode defaults for album + artistId + const defaults = (item?.['resource'] as Record | undefined)?.['defaults'] as string | undefined; + const defs = decodeDefaults(defaults); + const artistId = defs?.['artistId'] as string | undefined; + const albumName = defs?.['containerName'] as string | undefined; + const albumId = defs?.['containerId'] as string | undefined; + const durationMs = defs?.['durationMs'] as number | undefined; + + return { trackName, artUrl, artist, artistId, albumName, albumId, serviceId, accountId, durationMs }; +} + +export function trackQueryOptions(trackId: string, serviceId: string, rawAccountId: string) { + const accountId = rawAccountId.replace(/^sn_/, ''); + return { + queryKey: ['track', trackId] as const, + queryFn: async (): Promise => { + const r = await api.nowPlaying.track(trackId, { serviceId, accountId }); + if (!r.error) return parseResponse(r.data); + + // Fallback to YT Music when the original service isn't configured + if (serviceId !== '72711') { + const fallback = await api.nowPlaying.track(trackId, { serviceId: '72711', accountId }); + if (!fallback.error) return parseResponse(fallback.data); + } + + return null; + }, + staleTime: Infinity, + gcTime: 7 * 24 * 60 * 60 * 1000, // matches the persisted maxAge (see lib/queryPersistence) + retry: false, + }; +} + +export function useTrackDetails( + trackId: string | undefined, + serviceId: string | undefined, + rawAccountId: string | undefined +) { + return useQuery({ + ...trackQueryOptions(trackId ?? '', serviceId ?? '', rawAccountId ?? ''), + enabled: !!(trackId && serviceId && rawAccountId), + }); +} diff --git a/apps/mobile/src/logic/hooks/useUserProfile.ts b/apps/mobile/src/logic/hooks/useUserProfile.ts new file mode 100644 index 0000000..0427284 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useUserProfile.ts @@ -0,0 +1,19 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +export function useUserProfile(userName: string | undefined) { + return useQuery({ + queryKey: ['user-profile', userName], + queryFn: async () => { + const r = await window.sonos.fetchUserProfile(userName!); + return r ?? null; + }, + enabled: !!userName, + staleTime: 5 * 60_000, + }); +} + +export function useInvalidateUserProfile() { + const queryClient = useQueryClient(); + return (userName: string) => + queryClient.invalidateQueries({ queryKey: ['user-profile', userName] }); +} diff --git a/apps/mobile/src/logic/hooks/useUserStats.ts b/apps/mobile/src/logic/hooks/useUserStats.ts new file mode 100644 index 0000000..c020658 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useUserStats.ts @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query'; +import type { SonosItem } from '../types/sonos'; + +function toArtistItem(a: StatsArtist): SonosItem { + return { + type: 'ARTIST', + title: a.artist, + name: a.artist, + imageUrl: a.imageUrl, + resource: { + type: 'ARTIST', + id: { objectId: a.artistId, serviceId: a.serviceId, accountId: a.accountId }, + }, + }; +} + +function toAlbumItem(a: StatsAlbum): SonosItem { + return { + type: 'ITEM_ALBUM', + title: a.album, + name: a.album, + artist: a.artist, + imageUrl: a.imageUrl, + id: { objectId: a.albumId, serviceId: a.serviceId, accountId: a.accountId }, + summary: { content: a.artist }, + }; +} + +export function useUserStats(userName: string | undefined, count?: number) { + const { data, isLoading } = useQuery({ + queryKey: ['stats', 'alltime', userName ?? null, count ?? 10], + queryFn: () => window.sonos.fetchStats('alltime', userName, count), + enabled: !!userName, + staleTime: 5 * 60_000, + }); + + return { + topTracks: data?.topTracks ?? [], + artistItems: (data?.topArtists ?? []).map(toArtistItem), + albumItems: (data?.topAlbums ?? []).map(toAlbumItem), + totalEvents: data?.totalEvents ?? 0, + isLoading: !!userName && isLoading, + }; +} diff --git a/apps/mobile/src/logic/hooks/useUsers.ts b/apps/mobile/src/logic/hooks/useUsers.ts new file mode 100644 index 0000000..796c2c9 --- /dev/null +++ b/apps/mobile/src/logic/hooks/useUsers.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; + +export function useUsers(enabled = true, includeSelf = false) { + return useQuery({ + // Keyed on includeSelf — otherwise the "everyone else" list served to + // HomePanel would be handed to callers that need the whole room. + queryKey: ['users', includeSelf], + queryFn: () => window.sonos.fetchUsers(includeSelf), + enabled, + staleTime: 5 * 60_000, + }); +} diff --git a/apps/mobile/src/logic/lib/__tests__/detailLogic.test.ts b/apps/mobile/src/logic/lib/__tests__/detailLogic.test.ts new file mode 100644 index 0000000..f310cab --- /dev/null +++ b/apps/mobile/src/logic/lib/__tests__/detailLogic.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { + albumMetaLine, + buildBrowseItem, + canMoveDown, + canMoveUp, + createPlaylistRoute, + geniusToBlocks, + moveItem, + parseDetailParams, + parsePendingTrack, + trackSheetItem, +} from '../../../ui/detail/detailLogic'; +import type { AlbumTrack } from '../../hooks/useAlbumBrowse'; + +describe('parseDetailParams', () => { + it('reads strings, first of arrays, strips sn_ and drops empties', () => { + expect(parseDetailParams({ id: 'a', serviceId: ['1', '2'], accountId: 'sn_9', defaults: '', name: 'X' })).toEqual({ + id: 'a', + serviceId: '1', + accountId: '9', + defaults: undefined, + name: 'X', + }); + }); +}); + +describe('buildBrowseItem', () => { + it('returns null without id, else a typed resource', () => { + expect(buildBrowseItem('ALBUM', {})).toBeNull(); + const item = buildBrowseItem('PLAYLIST', { id: 'p', serviceId: 's', accountId: 'a', defaults: 'd', name: 'N' }); + expect(item?.resource).toEqual({ type: 'PLAYLIST', id: { objectId: 'p', serviceId: 's', accountId: 'a' }, defaults: 'd' }); + expect(item?.type).toBe('PLAYLIST'); + }); +}); + +describe('albumMetaLine', () => { + it('joins songs, minutes and year, omitting empties', () => { + expect(albumMetaLine(10, 2400, 1999)).toBe('10 songs • 40 min • 1999'); + expect(albumMetaLine(1, 20, null)).toBe('1 songs'); + }); +}); + +describe('moveItem', () => { + it('moves forward and back, and ignores out-of-range', () => { + expect(moveItem(['a', 'b', 'c'], 0, 2)).toEqual(['b', 'c', 'a']); + expect(moveItem(['a', 'b', 'c'], 2, 1)).toEqual(['a', 'c', 'b']); + expect(moveItem(['a', 'b'], 0, 5)).toEqual(['a', 'b']); + }); + it('bounds', () => { + expect(canMoveUp(0)).toBe(false); + expect(canMoveDown(2, 3)).toBe(false); + expect(canMoveDown(1, 3)).toBe(true); + }); +}); + +describe('create-playlist route', () => { + it('round-trips a pending track', () => { + const t = { uri: 'u', trackName: 'T', artist: 'A', serviceId: 's', accountId: 'a', addedBy: 'me', addedAt: 0 }; + const href = createPlaylistRoute(t); + const q = decodeURIComponent(href.split('track=')[1]); + expect(parsePendingTrack(q)).toEqual(t); + expect(createPlaylistRoute()).toBe('/create-playlist'); + expect(parsePendingTrack('nope')).toBeNull(); + }); +}); + +describe('trackSheetItem', () => { + const track = { + title: 'S', + ordinal: 1, + durationSeconds: 10, + artUrl: 'u', + id: { objectId: 't1', serviceId: 's', accountId: 'sn_a' }, + artists: ['B'], + artistObjects: [{ name: 'B', objectId: 'b1' }], + albumName: 'LP', + albumId: 'l1', + explicit: true, + raw: { title: 'S', resource: { type: 'TRACK', id: { objectId: 't1' } } }, + } as unknown as AlbumTrack; + it('produces a normalised track item with artist and optional album ids', () => { + const it = trackSheetItem(track, { includeAlbum: true }); + expect(it.id).toEqual({ objectId: 't1', serviceId: 's', accountId: 'a' }); + expect(it.artists?.[0].id?.objectId).toBe('b1'); + expect((it.album as { id: { objectId: string } }).id.objectId).toBe('l1'); + expect(trackSheetItem(track, {}).album).toBeUndefined(); + }); +}); + +describe('geniusToBlocks', () => { + it('flattens paragraphs, inline styles and links', () => { + const tree: GeniusDomNode = { + tag: 'root', + children: [ + { tag: 'p', children: ['Hello ', { tag: 'strong', children: ['bold'] }, ' and ', { tag: 'a', attributes: { href: 'http://x' }, children: ['link'] }, '.'] }, + { tag: 'p', children: ['Line1', { tag: 'br' }, 'Line2'] }, + { tag: 'ul', children: [{ tag: 'li', children: ['item'] }] }, + { tag: 'img' }, + { tag: 'p', children: [' '] }, + ], + }; + const blocks = geniusToBlocks(tree); + expect(blocks).toHaveLength(3); + expect(blocks[0].runs.map((r) => r.text).join('')).toBe('Hello bold and link.'); + expect(blocks[0].runs.find((r) => r.bold)?.text).toBe('bold'); + expect(blocks[0].runs.find((r) => r.href)?.href).toBe('http://x'); + expect(blocks[1].runs.map((r) => r.text).join('')).toBe('Line1\nLine2'); + expect(blocks[2].kind).toBe('li'); + }); + it('handles null, strings and bare inline roots', () => { + expect(geniusToBlocks(null)).toEqual([]); + expect(geniusToBlocks('plain')[0].runs[0].text).toBe('plain'); + }); +}); diff --git a/apps/mobile/src/logic/lib/__tests__/favouriteTrack.test.ts b/apps/mobile/src/logic/lib/__tests__/favouriteTrack.test.ts new file mode 100644 index 0000000..77a3b7f --- /dev/null +++ b/apps/mobile/src/logic/lib/__tests__/favouriteTrack.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { applyFavourite, buildFavouriteTrack, decideFavourite, isTrackFavourited } from '../../../ui/nowplaying/favouriteTrack'; +import { hexToRgb, hexToTintRgb, pickTintFromColors, rgba } from '../../../ui/nowplaying/colorMath'; + +const t = (uri: string) => ({ uri, trackName: uri, artist: 'a', serviceId: 's', accountId: 'c', addedBy: 'me', addedAt: 1 }) as PlaylistTrack; + +describe('favourite decisions', () => { + it('noop while loading or without a uri', () => { + expect(decideFavourite(undefined, 'x', false)).toBe('noop'); + expect(decideFavourite('me', null, false)).toBe('noop'); + }); + it('prompts when the display name is unset', () => { + expect(decideFavourite(null, 'x', false)).toBe('prompt-name'); + }); + it('adds or removes', () => { + expect(decideFavourite('me', 'x', false)).toBe('add'); + expect(decideFavourite('me', 'x', true)).toBe('remove'); + }); + it('detects favourited', () => { + expect(isTrackFavourited({ tracks: [t('a')] }, 'a')).toBe(true); + expect(isTrackFavourited({ tracks: [t('a')] }, 'b')).toBe(false); + expect(isTrackFavourited(undefined, 'a')).toBe(false); + }); + it('applies optimistic changes', () => { + const doc = { tracks: [t('a')] }; + expect(applyFavourite(doc, 'add', t('b')).tracks.map((x) => x.uri)).toEqual(['a', 'b']); + expect(applyFavourite(doc, 'remove', t('a')).tracks).toEqual([]); + expect(doc.tracks).toHaveLength(1); + }); + it('builds a playlist track', () => { + const tr = buildFavouriteTrack({ uri: 'u', trackName: 'n', artist: undefined, serviceId: null, accountId: 'x' }, 'me', 5); + expect(tr).toMatchObject({ uri: 'u', artist: '', serviceId: '', addedBy: 'me', addedAt: 5, imageUrl: null }); + }); +}); + +describe('colour maths', () => { + it('parses hex', () => { + expect(hexToRgb('#ff8000')).toEqual([255, 128, 0]); + expect(hexToRgb('f80')).toEqual([255, 136, 0]); + expect(hexToRgb('#ff800080')).toEqual([255, 128, 0]); + expect(hexToRgb('nope')).toBeNull(); + expect(hexToRgb(null)).toBeNull(); + }); + it('rejects near-black and near-white', () => { + expect(hexToTintRgb('#050505')).toBeNull(); + expect(hexToTintRgb('#fafafa')).toBeNull(); + expect(hexToTintRgb('#c83232')).toBe('200, 50, 50'); + }); + it('falls through vibrant -> dominant -> average', () => { + expect(pickTintFromColors({ vibrant: '#000000', dominant: '#2a6df4', average: '#c83232' })).toBe('42, 109, 244'); + expect(pickTintFromColors({ vibrant: '#000000', dominant: '#ffffff', average: '#c83232' })).toBe('200, 50, 50'); + expect(pickTintFromColors({ vibrant: '#000000' })).toBeNull(); + expect(pickTintFromColors(null)).toBeNull(); + }); + it('formats rgba', () => { + expect(rgba('1, 2, 3', 0.55)).toBe('rgba(1, 2, 3, 0.55)'); + }); +}); diff --git a/apps/mobile/src/logic/lib/__tests__/identityHelpers.test.ts b/apps/mobile/src/logic/lib/__tests__/identityHelpers.test.ts new file mode 100644 index 0000000..43468b9 --- /dev/null +++ b/apps/mobile/src/logic/lib/__tests__/identityHelpers.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { filterUsers, isCurrentIdentity, normaliseAvatarUrl, sortUsers } from '../../../ui/social/identity/identityHelpers'; + +const u = (userId: string) => ({ userId }); + +describe('identityHelpers', () => { + it('normalises avatar urls', () => { + expect(normaliseAvatarUrl(null)).toBeNull(); + expect(normaliseAvatarUrl(' ')).toBeNull(); + expect(normaliseAvatarUrl('//cdn/x.png')).toBe('https://cdn/x.png'); + expect(normaliseAvatarUrl('http://cdn/x.png')).toBe('https://cdn/x.png'); + expect(normaliseAvatarUrl(' https://cdn/x.png ')).toBe('https://cdn/x.png'); + }); + it('sorts case-insensitively, dedupes and drops blanks', () => { + expect(sortUsers([u('bob'), u('Alice'), u('BOB'), u(' '), u('carl')]).map((x) => x.userId)).toEqual(['Alice', 'bob', 'carl']); + }); + it('filters by substring', () => { + const list = [u('Alice'), u('Malice'), u('Bob')]; + expect(filterUsers(list, ' ALI ').map((x) => x.userId)).toEqual(['Alice', 'Malice']); + expect(filterUsers(list, '')).toHaveLength(3); + expect(filterUsers(list, 'zz')).toEqual([]); + }); + it('matches current identity', () => { + expect(isCurrentIdentity('Alice', ' alice ')).toBe(true); + expect(isCurrentIdentity('Alice', null)).toBe(false); + expect(isCurrentIdentity('Alice', 'Bob')).toBe(false); + }); +}); diff --git a/apps/mobile/src/logic/lib/__tests__/itemHelpers.test.ts b/apps/mobile/src/logic/lib/__tests__/itemHelpers.test.ts new file mode 100644 index 0000000..e25804a --- /dev/null +++ b/apps/mobile/src/logic/lib/__tests__/itemHelpers.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect } from 'vitest'; +import { + fmtDuration, + fmtTime, + getName, + getItemArt, + getSub, + getArtist, + getAlbum, + isAlbum, + isTrack, + isArtist, + isPlaylist, + isProgram, + extractItems, + parseServiceSearch, + resolveAlbumParams, + resolveArtistParams, + decodeDefaults, +} from '../itemHelpers'; +import type { SonosItem, QueueItem } from '../../types/sonos'; +import type { ServiceSearch } from '../../types/ServiceSearch'; + +// ─── fmtDuration ───────────────────────────────────────────────────────────── + +describe('fmtDuration', () => { + it('formats zero seconds', () => { + expect(fmtDuration(0)).toBe('0:00'); + }); + + it('formats sub-minute seconds', () => { + expect(fmtDuration(9)).toBe('0:09'); + expect(fmtDuration(59)).toBe('0:59'); + }); + + it('formats exactly one minute', () => { + expect(fmtDuration(60)).toBe('1:00'); + }); + + it('formats minutes and seconds', () => { + expect(fmtDuration(90)).toBe('1:30'); + expect(fmtDuration(3661)).toBe('61:01'); + }); + + it('pads single-digit seconds', () => { + expect(fmtDuration(65)).toBe('1:05'); + }); +}); + +// ─── fmtTime ───────────────────────────────────────────────────────────────── + +describe('fmtTime', () => { + it('formats zero ms', () => { + expect(fmtTime(0)).toBe('0:00'); + }); + + it('formats milliseconds to m:ss', () => { + expect(fmtTime(61000)).toBe('1:01'); + expect(fmtTime(3600000)).toBe('60:00'); + }); + + it('truncates fractional seconds', () => { + expect(fmtTime(1500)).toBe('0:01'); + }); +}); + +// ─── getName ───────────────────────────────────────────────────────────────── + +describe('getName', () => { + it('returns name when present', () => { + expect(getName({ name: 'Foo' } as SonosItem)).toBe('Foo'); + }); + + it('falls back to title', () => { + expect(getName({ title: 'Bar' } as SonosItem)).toBe('Bar'); + }); + + it('prefers name over title', () => { + expect(getName({ name: 'Foo', title: 'Bar' } as SonosItem)).toBe('Foo'); + }); + + it('falls back to resource.name', () => { + expect(getName({ resource: { name: 'Res', type: 'TRACK', id: { objectId: 'x', serviceId: 'y', accountId: 'z' } } } as SonosItem)).toBe('Res'); + }); + + it('falls back to track.name', () => { + expect(getName({ track: { name: 'Track' } } as SonosItem)).toBe('Track'); + }); + + it('returns (unknown) when nothing is present', () => { + expect(getName({} as SonosItem)).toBe('(unknown)'); + }); +}); + +// ─── getItemArt ────────────────────────────────────────────────────────────── + +describe('getItemArt', () => { + it('returns tile1x1 from images object (browse shape)', () => { + expect(getItemArt({ images: { tile1x1: 'https://example.com/tile.jpg' } } as unknown as SonosItem)).toBe('https://example.com/tile.jpg'); + }); + + it('returns imageUrl fallback', () => { + expect(getItemArt({ imageUrl: 'https://example.com/img.jpg' } as SonosItem)).toBe('https://example.com/img.jpg'); + }); + + it('returns track.imageUrl', () => { + expect(getItemArt({ track: { imageUrl: 'https://example.com/track.jpg' } } as SonosItem)).toBe('https://example.com/track.jpg'); + }); + + it('returns null when no art', () => { + expect(getItemArt({} as SonosItem)).toBeNull(); + }); +}); + +// ─── Type predicates ───────────────────────────────────────────────────────── + +describe('isAlbum', () => { + it('identifies ALBUM type', () => { + expect(isAlbum({ type: 'ALBUM' } as SonosItem)).toBe(true); + }); + + it('identifies via resource.type', () => { + expect(isAlbum({ resource: { type: 'ALBUM', id: { objectId: '', serviceId: '', accountId: '' } } } as SonosItem)).toBe(true); + }); + + it('is case-insensitive', () => { + expect(isAlbum({ type: 'album' } as SonosItem)).toBe(true); + }); + + it('rejects non-album types', () => { + expect(isAlbum({ type: 'TRACK' } as SonosItem)).toBe(false); + expect(isAlbum({ type: 'ARTIST' } as SonosItem)).toBe(false); + expect(isAlbum({} as SonosItem)).toBe(false); + }); +}); + +describe('isTrack', () => { + it('identifies TRACK type', () => { + expect(isTrack({ type: 'TRACK' } as SonosItem)).toBe(true); + }); + + it('rejects non-track types', () => { + expect(isTrack({ type: 'ALBUM' } as SonosItem)).toBe(false); + }); +}); + +describe('isArtist', () => { + it('identifies ARTIST type', () => { + expect(isArtist({ type: 'ARTIST' } as SonosItem)).toBe(true); + }); + + it('rejects non-artist types', () => { + expect(isArtist({ type: 'ALBUM' } as SonosItem)).toBe(false); + }); +}); + +describe('isPlaylist', () => { + it('identifies PLAYLIST type', () => { + expect(isPlaylist({ type: 'PLAYLIST' } as SonosItem)).toBe(true); + }); + + it('rejects ITEM_PLAYLIST (container browse, not browseable playlist)', () => { + expect(isPlaylist({ type: 'ITEM_PLAYLIST' } as SonosItem)).toBe(false); + }); + + it('rejects non-playlist types', () => { + expect(isPlaylist({ type: 'ALBUM' } as SonosItem)).toBe(false); + }); +}); + +describe('isProgram', () => { + it('identifies PROGRAM type', () => { + expect(isProgram({ type: 'PROGRAM' } as SonosItem)).toBe(true); + }); + + it('rejects partial match (e.g. PROGRAMMATIC would match TRACK, not this)', () => { + expect(isProgram({ type: 'TRACK' } as SonosItem)).toBe(false); + }); +}); + +// ─── extractItems ───────────────────────────────────────────────────────────── + +describe('extractItems', () => { + const item1 = { name: 'Track 1', type: 'TRACK' } as SonosItem; + const item2 = { name: 'Track 2', type: 'TRACK' } as SonosItem; + + it('handles null/undefined', () => { + expect(extractItems(null)).toEqual([]); + expect(extractItems(undefined)).toEqual([]); + }); + + it('returns bare array as-is', () => { + expect(extractItems([item1, item2])).toEqual([item1, item2]); + }); + + it('extracts from .items property', () => { + expect(extractItems({ items: [item1] })).toEqual([item1]); + }); + + it('extracts from .resources property', () => { + expect(extractItems({ resources: [item2] })).toEqual([item2]); + }); + + it('extracts from .section.items property', () => { + expect(extractItems({ section: { items: [item1, item2] } })).toEqual([item1, item2]); + }); + + it('flattens .services into tagged items', () => { + const data = { + services: [ + { serviceId: 'gm', results: { tracks: [item1] } }, + ], + }; + const result = extractItems(data); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ name: 'Track 1', _svcId: 'gm' }); + }); + + it('returns empty array for unrecognised shape', () => { + expect(extractItems({ foo: 'bar' })).toEqual([]); + }); +}); + +// ─── parseServiceSearch ─────────────────────────────────────────────────────── + +describe('parseServiceSearch', () => { + it('parses TRACKS section into SonosItems with type TRACK', () => { + const data: ServiceSearch = { + resourceOrder: ['TRACKS'], + TRACKS: { + resources: [ + { + name: 'Bohemian Rhapsody', + id: { objectId: 'trk1', serviceId: 'gm', accountId: 'acc1' }, + explicit: false, + durationMs: 354000, + } as ServiceSearch['TRACKS']['resources'][0], + ], + }, + } as unknown as ServiceSearch; + + const result = parseServiceSearch(data); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('TRACK'); + expect(result[0].name).toBe('Bohemian Rhapsody'); + }); + + it('parses ARTISTS section', () => { + const data: ServiceSearch = { + resourceOrder: ['ARTISTS'], + ARTISTS: { + resources: [ + { name: 'Queen', id: { objectId: 'art1', serviceId: 'gm', accountId: 'acc1' } }, + ], + }, + } as unknown as ServiceSearch; + + const result = parseServiceSearch(data); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('ARTIST'); + expect(result[0].name).toBe('Queen'); + }); + + it('respects resourceOrder when multiple sections present', () => { + const data: ServiceSearch = { + resourceOrder: ['ARTISTS', 'TRACKS'], + ARTISTS: { resources: [{ name: 'Artist', id: { objectId: 'a', serviceId: 's', accountId: 'c' } }] }, + TRACKS: { resources: [{ name: 'Track', id: { objectId: 't', serviceId: 's', accountId: 'c' } }] }, + } as unknown as ServiceSearch; + + const result = parseServiceSearch(data); + expect(result[0].type).toBe('ARTIST'); + expect(result[1].type).toBe('TRACK'); + }); + + it('handles empty sections gracefully', () => { + const data: ServiceSearch = { + resourceOrder: ['TRACKS'], + TRACKS: { resources: [] }, + } as unknown as ServiceSearch; + + expect(parseServiceSearch(data)).toEqual([]); + }); + + it('parses ALBUMS section with artists', () => { + const data = { + resourceOrder: ['ALBUMS'], + ALBUMS: { + resources: [{ + name: 'A Night at the Opera', + id: { objectId: 'alb1', serviceId: 'gm', accountId: 'acc1' }, + artists: [{ name: 'Queen', id: { objectId: 'art1', serviceId: 'gm', accountId: 'acc1' } }], + explicit: false, + summary: { content: 'Classic rock' }, + }], + }, + } as unknown as ServiceSearch; + + const result = parseServiceSearch(data); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('ALBUM'); + expect(result[0].name).toBe('A Night at the Opera'); + expect(result[0].artists?.[0].name).toBe('Queen'); + }); + + it('parses TRACKS section with artists (covers artists.map callback)', () => { + const data = { + resourceOrder: ['TRACKS'], + TRACKS: { + resources: [{ + name: 'Bohemian Rhapsody', + id: { objectId: 'trk1', serviceId: 'gm', accountId: 'acc1' }, + artists: [{ name: 'Queen', id: { objectId: 'art1', serviceId: 'gm', accountId: 'acc1' } }], + explicit: false, + durationMs: 354000, + }], + }, + } as unknown as ServiceSearch; + + const result = parseServiceSearch(data); + expect(result[0].artists?.[0].name).toBe('Queen'); + }); + + it('parses PLAYLISTS section', () => { + const data = { + resourceOrder: ['PLAYLISTS'], + PLAYLISTS: { + resources: [{ + name: 'My Playlist', + id: { objectId: 'pl1', serviceId: 'gm', accountId: 'acc1' }, + }], + }, + } as unknown as ServiceSearch; + + const result = parseServiceSearch(data); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('PLAYLIST'); + expect(result[0].name).toBe('My Playlist'); + }); + + it('parses PODCASTS section', () => { + const data = { + resourceOrder: ['PODCASTS'], + PODCASTS: { + resources: [{ + name: 'Tech Talk', + id: { objectId: 'pod1', serviceId: 'gm', accountId: 'acc1' }, + }], + }, + } as unknown as ServiceSearch; + + const result = parseServiceSearch(data); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('PODCAST'); + expect(result[0].name).toBe('Tech Talk'); + }); + + it('uses default resourceOrder when not specified', () => { + const data = { + ARTISTS: { resources: [{ name: 'A', id: { objectId: 'a', serviceId: 's', accountId: 'c' } }] }, + ALBUMS: { resources: [{ name: 'B', id: { objectId: 'b', serviceId: 's', accountId: 'c' } }] }, + TRACKS: { resources: [{ name: 'C', id: { objectId: 'c', serviceId: 's', accountId: 'c' } }] }, + } as unknown as ServiceSearch; + + const result = parseServiceSearch(data); + expect(result.map(r => r.type)).toEqual(['ARTIST', 'ALBUM', 'TRACK']); + }); +}); + +// ─── resolveAlbumParams ─────────────────────────────────────────────────────── + +describe('resolveAlbumParams', () => { + it('extracts from resource.id and strips sn_ prefix from accountId', () => { + const item: SonosItem = { + type: 'ALBUM', + resource: { + type: 'ALBUM', + id: { objectId: 'alb1', serviceId: 'gm', accountId: 'sn_abc' }, + defaults: 'eyJmb28iOiJiYXIifQ==', + }, + }; + const result = resolveAlbumParams(item); + expect(result.albumId).toBe('alb1'); + expect(result.serviceId).toBe('gm'); + expect(result.accountId).toBe('abc'); + expect(result.defaults).toBe('eyJmb28iOiJiYXIifQ=='); + }); + + it('falls back to item.id when no resource.id', () => { + const item: SonosItem = { + type: 'ALBUM', + id: { objectId: 'alb2', serviceId: 'sp', accountId: 'usr1' }, + }; + const result = resolveAlbumParams(item); + expect(result.albumId).toBe('alb2'); + expect(result.serviceId).toBe('sp'); + expect(result.accountId).toBe('usr1'); + }); + + it('returns undefined fields for bare item', () => { + const result = resolveAlbumParams({ type: 'ALBUM' } as SonosItem); + expect(result.albumId).toBeUndefined(); + expect(result.serviceId).toBeUndefined(); + }); +}); + +// ─── resolveArtistParams ────────────────────────────────────────────────────── + +describe('resolveArtistParams', () => { + it('extracts artistId from direct ARTIST item', () => { + const item: SonosItem = { + type: 'ARTIST', + name: 'Queen', + resource: { + type: 'ARTIST', + id: { objectId: 'art1', serviceId: 'gm', accountId: 'acc1' }, + }, + }; + const result = resolveArtistParams(item); + expect(result.artistId).toBe('art1'); + expect(result.name).toBe('Queen'); + }); + + it('decodes artistId from base64 defaults on album item', () => { + const defaults = btoa(JSON.stringify({ artistId: 'art99', artist: 'Led Zeppelin' })); + const item: SonosItem = { + type: 'ALBUM', + resource: { + type: 'ALBUM', + id: { objectId: 'alb1', serviceId: 'gm', accountId: 'acc1' }, + defaults, + }, + }; + const result = resolveArtistParams(item); + expect(result.artistId).toBe('art99'); + expect(result.name).toBe('Led Zeppelin'); + }); + + it('returns undefined artistId when nothing can be resolved', () => { + const result = resolveArtistParams({ type: 'ALBUM' } as SonosItem); + expect(result.artistId).toBeUndefined(); + }); +}); + +// ─── getSub / getArtist / getAlbum ─────────────────────────────────────────── + +describe('getSub', () => { + it('returns artist name from subtitle field', () => { + const item = { type: 'TRACK', subtitle: 'The Beatles' } as unknown as SonosItem; + expect(getSub(item)).toBe('The Beatles'); + }); + + it('returns empty string when no artist info present', () => { + expect(getSub({} as SonosItem)).toBe(''); + }); + + it('returns summary.content when present', () => { + const item = { type: 'TRACK', subtitle: 'Artist', summary: { content: 'A great track' } } as unknown as SonosItem; + expect(getSub(item)).toBe('A great track'); + }); +}); + +describe('getArtist', () => { + it('returns artist from track.artist string', () => { + const item = { track: { artist: 'The Beatles', name: 'Come Together' } } as unknown as QueueItem; + expect(getArtist(item)).toBe('The Beatles'); + }); + + it('returns empty string for null/falsy artist', () => { + const item = { track: { artist: null, name: 'Song' } } as unknown as QueueItem; + expect(getArtist(item)).toBe(''); + }); + + it('extracts name from artist object', () => { + const item = { track: { artist: { name: 'Queen' } } } as unknown as QueueItem; + expect(getArtist(item)).toBe('Queen'); + }); +}); + +describe('getAlbum', () => { + it('returns album name string directly', () => { + const item = { track: { album: 'Abbey Road' } } as unknown as QueueItem; + expect(getAlbum(item)).toBe('Abbey Road'); + }); + + it('extracts name from album object', () => { + const item = { track: { album: { name: 'Abbey Road' } } } as unknown as QueueItem; + expect(getAlbum(item)).toBe('Abbey Road'); + }); + + it('returns empty string when no album', () => { + const item = {} as QueueItem; + expect(getAlbum(item)).toBe(''); + }); +}); + +// ─── decodeDefaults ─────────────────────────────────────────────────────────── + +describe('decodeDefaults', () => { + it('decodes valid base64 JSON', () => { + const payload = { artistId: 'abc', foo: 42 }; + const encoded = btoa(JSON.stringify(payload)); + expect(decodeDefaults(encoded)).toEqual(payload); + }); + + it('returns null for undefined input', () => { + expect(decodeDefaults(undefined)).toBeNull(); + }); + + it('returns null for invalid base64', () => { + expect(decodeDefaults('!!!not-base64!!!')).toBeNull(); + }); +}); diff --git a/apps/mobile/src/logic/lib/__tests__/mediaLogic.test.ts b/apps/mobile/src/logic/lib/__tests__/mediaLogic.test.ts new file mode 100644 index 0000000..1b59204 --- /dev/null +++ b/apps/mobile/src/logic/lib/__tests__/mediaLogic.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + albumRouteForItem, + artistRouteForItem, + makeNavigate, + routeForItem, + routeForPlaylist, +} from '../../../ui/media/routes'; +import { actionsForItem, shapeSearchSections, toPlaylistTrack } from '../../../ui/media/itemLogic'; +import { useDebouncedValue } from '../../../ui/media/useDebouncedValue'; +import { fetchYtmSections } from '../ytmSections'; +import type { SonosItem } from '../../types/sonos'; + +const album: SonosItem = { + type: 'ALBUM', + name: 'Rebel Heart', + resource: { type: 'ALBUM', id: { objectId: 'alb/1', serviceId: 's1', accountId: 'sn_a1' } }, +}; +const track: SonosItem = { + type: 'TRACK', + name: 'Song', + artists: [{ name: 'Band', id: { objectId: 'art1', serviceId: 's1', accountId: 'sn_a1' } }], + album: { name: 'LP', id: { objectId: 'alb2', serviceId: 's1', accountId: 'sn_a1' } }, + id: { objectId: 'trk1', serviceId: 's1', accountId: 'a1' }, +}; + +describe('routes', () => { + it('routes albums with encoded id and stripped sn_ account', () => { + expect(routeForItem(album)).toBe('/album/alb%2F1?serviceId=s1&accountId=a1&name=Rebel%20Heart'); + }); + it('routes artists and containers', () => { + expect(routeForItem({ type: 'ARTIST', name: 'X', resource: { type: 'ARTIST', id: { objectId: 'a', serviceId: 's' } } })).toBe('/artist/a?serviceId=s&name=X'); + expect(routeForItem({ type: 'ITEM_AUDIO', name: 'C', resource: { type: 'ITEM_AUDIO', id: { objectId: 'c' } } })).toMatch(/^\/container\/c/); + }); + it('returns null without an id', () => { + expect(routeForItem({ type: 'ALBUM', name: 'x' })).toBeNull(); + }); + it('derives album/artist routes from a track', () => { + expect(albumRouteForItem(track)).toBe('/album/alb2?serviceId=s1&accountId=a1&name=LP'); + expect(artistRouteForItem(track)).toBe('/artist/art1?serviceId=s1&accountId=a1&name=Band'); + }); + it('playlist route', () => expect(routeForPlaylist('fav-me')).toBe('/playlist/fav-me')); + it('makeNavigate rebuilds the href from the item', () => { + const push = vi.fn(); + makeNavigate(push)('/album/alb%2F1', { state: { item: album } }); + expect(push).toHaveBeenCalledWith(routeForItem(album)); + makeNavigate(push)('/x'); + expect(push).toHaveBeenLastCalledWith('/x'); + }); +}); + +describe('itemLogic', () => { + it('shapes search sections', () => { + const s = shapeSearchSections([album, track, { type: 'ARTIST', name: 'A' }, { type: 'PLAYLIST', name: 'P' }, { type: 'PODCAST', name: 'Q' }]); + expect([s.albums.length, s.tracks.length, s.artists.length, s.playlists.length, s.others.length]).toEqual([1, 1, 1, 1, 1]); + }); + it('builds a playlist track', () => { + expect(toPlaylistTrack(track, 'me')).toMatchObject({ uri: 'trk1', trackName: 'Song', artist: 'Band', albumName: 'LP', addedBy: 'me' }); + }); + it('picks actions per item kind', () => { + const ctx = { hasAlbumRoute: true, hasArtistRoute: true, canPlaylist: true }; + expect(actionsForItem(track, ctx)).toEqual(['playNext', 'addToQueue', 'goToAlbum', 'goToArtist', 'favourite', 'addToPlaylist']); + expect(actionsForItem(album, ctx)).toEqual(['playNext', 'addToQueue', 'goToArtist']); + }); +}); + +describe('useDebouncedValue', () => { + it('only updates after the delay', () => { + vi.useFakeTimers(); + const { result, rerender } = renderHook(({ v }) => useDebouncedValue(v, 300), { initialProps: { v: 'a' } }); + rerender({ v: 'ab' }); + expect(result.current).toBe('a'); + act(() => { vi.advanceTimersByTime(300); }); + expect(result.current).toBe('ab'); + vi.useRealTimers(); + }); +}); + +describe('fetchYtmSections', () => { + const mockFetch = vi.fn(); + beforeEach(() => { + mockFetch.mockReset(); + (window as unknown as { sonos: unknown }).sonos = { fetch: mockFetch }; + }); + const entry = (title: string, id: string): SonosItem => ({ title, resource: { type: 'CONTAINER', id: { objectId: id, serviceId: 's', accountId: 'a' } } }); + + it('returns empty on root error', async () => { + mockFetch.mockResolvedValueOnce({ error: 'boom' }); + expect(await fetchYtmSections()).toEqual({ forYou: [], newReleases: [], charts: [] }); + }); + it('browses Home/New releases/Charts and prepends Supermix', async () => { + const root = { sections: { items: [{ items: [entry('Home', 'h'), entry('New releases', 'n'), entry('Charts', 'c'), entry('My Supermix', 'm')] }] } }; + mockFetch.mockImplementation(async (req: { pathParams?: { containerId?: string } }) => { + const id = req.pathParams?.containerId; + if (id === 'root') return { data: root }; + return { data: { items: [{ title: `item-${id}` }] } }; + }); + const r = await fetchYtmSections(); + expect(r.forYou.map((i) => i.title)).toEqual(['My Supermix', 'item-h']); + expect(r.newReleases[0].title).toBe('item-n'); + expect(r.charts[0].title).toBe('item-c'); + }); + + it('falls back to the shelves inside Home when the root has no New releases / Charts', async () => { + const root = { sections: { items: [{ items: [entry('Home', 'h'), entry('Library', 'l'), entry('My Supermix', 'm')] }] } }; + const homeChildren = [entry('Quick picks', 'qp'), entry('New releases', 'nr'), entry('Top songs', 'ts'), entry('Top songs', 'ts2')]; + mockFetch.mockImplementation(async (req: { pathParams?: { containerId?: string } }) => { + const id = req.pathParams?.containerId; + if (id === 'root') return { data: root }; + if (id === 'h') return { data: { items: homeChildren } }; + return { data: { items: [{ title: `item-${id}` }] } }; + }); + const r = await fetchYtmSections(); + expect(r.newReleases.map((i) => i.title)).toEqual(['item-nr']); + expect(r.charts.map((i) => i.title)).toEqual(['item-ts']); // the first 'Top songs' shelf + expect(r.forYou.map((i) => i.title)).toEqual(['My Supermix', 'Quick picks', 'New releases', 'Top songs', 'Top songs']); + }); + + it('prefers the root entries when they exist', async () => { + const root = { sections: { items: [{ items: [entry('Home', 'h'), entry('New releases', 'rootnr'), entry('Charts', 'rootc')] }] } }; + mockFetch.mockImplementation(async (req: { pathParams?: { containerId?: string } }) => { + const id = req.pathParams?.containerId; + if (id === 'root') return { data: root }; + if (id === 'h') return { data: { items: [entry('New releases', 'homenr'), entry('Top songs', 'homets')] } }; + return { data: { items: [{ title: `item-${id}` }] } }; + }); + const r = await fetchYtmSections(); + expect(r.newReleases[0].title).toBe('item-rootnr'); + expect(r.charts[0].title).toBe('item-rootc'); + }); +}); diff --git a/apps/mobile/src/logic/lib/__tests__/prefetch.test.ts b/apps/mobile/src/logic/lib/__tests__/prefetch.test.ts new file mode 100644 index 0000000..ce14989 --- /dev/null +++ b/apps/mobile/src/logic/lib/__tests__/prefetch.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { createSeen, currentQueueIndex, prefetchWindow, runLimited } from '../prefetch'; + +const item = (n: number, ids = true) => ({ track: { id: `t${n}`, serviceId: ids ? 's' : null, accountId: ids ? 'a' : null } }); +const queue = (n: number) => Array.from({ length: n }, (_, i) => item(i)); + +describe('currentQueueIndex', () => { + it('prefers the objectId match', () => expect(currentQueueIndex(queue(10), 't6', '2')).toBe(6)); + it('falls back to the 1-based queueItemId cursor', () => expect(currentQueueIndex(queue(10), 'gone', '4')).toBe(3)); + it('ignores an out-of-range or junk cursor', () => { + expect(currentQueueIndex(queue(3), null, '9')).toBe(0); + expect(currentQueueIndex(queue(3), null, 'x')).toBe(0); + expect(currentQueueIndex(queue(3), null, null)).toBe(0); + }); +}); + +describe('prefetchWindow', () => { + it('orders: playing item and what follows, then just behind, then the head', () => { + const w = prefetchWindow(queue(100), 50, 3, 2, 2).map((i) => i.track.id); + expect(w).toEqual(['t50', 't51', 't52', 't53', 't49', 't48', 't0', 't1']); + }); + it('clamps to the queue and never repeats an item', () => { + const w = prefetchWindow(queue(5), 3, 30, 3, 12).map((i) => i.track.id); + expect(w).toEqual(['t3', 't4', 't2', 't1', 't0']); + expect(new Set(w).size).toBe(w.length); + }); + it('skips items without ids and handles an empty queue', () => { + const items = [item(0), item(1, false), item(2)]; + expect(prefetchWindow(items, 0).map((i) => i.track.id)).toEqual(['t0', 't2']); + expect(prefetchWindow([], 0)).toEqual([]); + }); +}); + +describe('runLimited', () => { + it('never exceeds the concurrency cap and runs everything', async () => { + let active = 0; + let peak = 0; + let done = 0; + const tasks = Array.from({ length: 12 }, () => async () => { + active++; + peak = Math.max(peak, active); + await new Promise((r) => setTimeout(r, 2)); + active--; + done++; + }); + await runLimited(tasks, 3); + expect(done).toBe(12); + expect(peak).toBeLessThanOrEqual(3); + }); + it('carries on past a failing task', async () => { + const ran: number[] = []; + await runLimited([async () => { ran.push(1); }, async () => { throw new Error('x'); }, async () => { ran.push(3); }], 1); + expect(ran).toEqual([1, 3]); + }); + it('stops starting new work once cancelled', async () => { + let cancelled = false; + const ran: number[] = []; + const tasks = Array.from({ length: 6 }, (_, i) => async () => { ran.push(i); if (i === 1) cancelled = true; }); + await runLimited(tasks, 1, () => cancelled); + expect(ran).toEqual([0, 1]); + }); + it('is a no-op for no tasks', async () => { + await expect(runLimited([], 4)).resolves.toBeUndefined(); + }); +}); + +describe('createSeen', () => { + it('reports first-time adds only', () => { + const s = createSeen(); + expect(s.add('a')).toBe(true); + expect(s.add('a')).toBe(false); + expect(s.has('a')).toBe(true); + }); + it('evicts the oldest past the cap', () => { + const s = createSeen(2); + s.add('a'); s.add('b'); s.add('c'); + expect(s.size).toBe(2); + expect(s.has('a')).toBe(false); + expect(s.has('c')).toBe(true); + }); +}); diff --git a/apps/mobile/src/logic/lib/__tests__/queryPersistence.test.ts b/apps/mobile/src/logic/lib/__tests__/queryPersistence.test.ts new file mode 100644 index 0000000..b67c992 --- /dev/null +++ b/apps/mobile/src/logic/lib/__tests__/queryPersistence.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { isSearchable } from '../searchRules'; +import { shouldPersistQuery } from '../queryPersistence'; + +const q = (queryKey: unknown[], status = 'success') => ({ queryKey, state: { status } }); + +describe('shouldPersistQuery', () => { + it('keeps loaded track details', () => expect(shouldPersistQuery(q(['track', 'abc']))).toBe(true)); + it('drops track queries that are loading or failed', () => { + expect(shouldPersistQuery(q(['track', 'abc'], 'pending'))).toBe(false); + expect(shouldPersistQuery(q(['track', 'abc'], 'error'))).toBe(false); + }); + it('drops everything that is not a track-detail query', () => { + for (const key of [['search', 'x'], ['ytm-sections'], ['playlist', 'fav-x'], ['track'], ['tracks', 'abc'], [['track', 'abc']]]) { + expect(shouldPersistQuery(q(key as unknown[]))).toBe(false); + } + }); +}); + +describe('isSearchable', () => { + it('needs two visible characters', () => { + expect(isSearchable('')).toBe(false); + expect(isSearchable('a')).toBe(false); + expect(isSearchable(' a ')).toBe(false); + expect(isSearchable('ab')).toBe(true); + expect(isSearchable(' ab ')).toBe(true); + }); +}); diff --git a/apps/mobile/src/logic/lib/__tests__/queueHelpers.test.ts b/apps/mobile/src/logic/lib/__tests__/queueHelpers.test.ts new file mode 100644 index 0000000..baa6f18 --- /dev/null +++ b/apps/mobile/src/logic/lib/__tests__/queueHelpers.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest'; +import { applyReorderLocally, expandToAlbumBlock } from '../queueHelpers'; + +// Using plain strings — applyReorderLocally is generic, no QueueItem needed here. +const items = ['A', 'B', 'C', 'D', 'E']; + +describe('applyReorderLocally', () => { + // ─── Single-item moves ──────────────────────────────────────────────────────── + + it('moves item from start to end', () => { + expect(applyReorderLocally(['A', 'B', 'C', 'D'], [0], 4)).toEqual(['B', 'C', 'D', 'A']); + }); + + it('moves item from end to start', () => { + expect(applyReorderLocally(['A', 'B', 'C', 'D'], [3], 0)).toEqual(['D', 'A', 'B', 'C']); + }); + + it('moves item one step forward', () => { + expect(applyReorderLocally(['A', 'B', 'C'], [0], 2)).toEqual(['B', 'A', 'C']); + }); + + it('moves item one step backward', () => { + expect(applyReorderLocally(['A', 'B', 'C'], [2], 1)).toEqual(['A', 'C', 'B']); + }); + + it('is a no-op when toIndex is already directly after the item', () => { + // Moving index 1 to position 2 (just after itself) — result unchanged + expect(applyReorderLocally(['A', 'B', 'C'], [1], 2)).toEqual(['A', 'B', 'C']); + }); + + it('is a no-op when toIndex equals the item index', () => { + expect(applyReorderLocally(['A', 'B', 'C'], [1], 1)).toEqual(['A', 'B', 'C']); + }); + + // ─── Multi-item moves ───────────────────────────────────────────────────────── + + it('moves two contiguous items to the end', () => { + expect(applyReorderLocally(items, [1, 2], 5)).toEqual(['A', 'D', 'E', 'B', 'C']); + }); + + it('moves two contiguous items to the start', () => { + expect(applyReorderLocally(items, [3, 4], 0)).toEqual(['D', 'E', 'A', 'B', 'C']); + }); + + it('moves non-contiguous items and preserves their relative order', () => { + // Moving indices 0, 2, 4 (A, C, E) to position 3 (before D in the original). + // Non-selected = [B, D]; only B has original index < 3 → insertAt = 1. + // Result: [B] + [A, C, E] + [D] = [B, A, C, E, D] + expect(applyReorderLocally(items, [0, 2, 4], 3)).toEqual(['B', 'A', 'C', 'E', 'D']); + }); + + it('moves non-contiguous items to position 0', () => { + expect(applyReorderLocally(items, [1, 3], 0)).toEqual(['B', 'D', 'A', 'C', 'E']); + }); + + it('sorts fromIndices before moving — order of fromIndices array should not matter', () => { + const forward = applyReorderLocally(items, [0, 2], 4); + const reversed = applyReorderLocally(items, [2, 0], 4); + expect(forward).toEqual(reversed); + expect(forward).toEqual(['B', 'D', 'A', 'C', 'E']); + }); + + // ─── Edge cases ─────────────────────────────────────────────────────────────── + + it('handles a single-element list', () => { + expect(applyReorderLocally(['X'], [0], 0)).toEqual(['X']); + expect(applyReorderLocally(['X'], [0], 1)).toEqual(['X']); + }); + + it('handles moving all items (no-op)', () => { + expect(applyReorderLocally(['A', 'B', 'C'], [0, 1, 2], 0)).toEqual(['A', 'B', 'C']); + }); + + it('toIndex at the very end equals length', () => { + expect(applyReorderLocally(['A', 'B', 'C'], [0], 3)).toEqual(['B', 'C', 'A']); + }); +}); + +describe('expandToAlbumBlock', () => { + it('selects a contiguous run of tracks sharing an albumId', () => { + const albumIds = ['A', 'A', 'A', 'B']; + expect([...expandToAlbumBlock(albumIds.length, 1, albumIds)].sort((a, b) => a - b)).toEqual([0, 1, 2]); + }); + + it('stops at the boundary of a different album', () => { + const albumIds = ['A', 'B', 'B', 'A']; + expect([...expandToAlbumBlock(albumIds.length, 1, albumIds)].sort((a, b) => a - b)).toEqual([1, 2]); + }); + + it('returns just the anchor when anchor has no albumId (no artist fallback)', () => { + const albumIds = [null, null, null]; + expect([...expandToAlbumBlock(albumIds.length, 0, albumIds)]).toEqual([0]); + }); + + it('returns just the anchor when an empty-string albumId is passed', () => { + const albumIds = ['', '', '']; + expect([...expandToAlbumBlock(albumIds.length, 0, albumIds)]).toEqual([0]); + }); + + it('returns just the anchor when neighbours have different albums', () => { + const albumIds = ['A', 'B', 'C']; + expect([...expandToAlbumBlock(albumIds.length, 1, albumIds)]).toEqual([1]); + }); + + it('handles anchor at the start of the list', () => { + const albumIds = ['A', 'A', 'B']; + expect([...expandToAlbumBlock(albumIds.length, 0, albumIds)].sort((a, b) => a - b)).toEqual([0, 1]); + }); + + it('handles anchor at the end of the list', () => { + const albumIds = ['B', 'A', 'A']; + expect([...expandToAlbumBlock(albumIds.length, 2, albumIds)].sort((a, b) => a - b)).toEqual([1, 2]); + }); + + it('returns just the anchor when anchor is out of bounds', () => { + const albumIds = ['A']; + expect([...expandToAlbumBlock(albumIds.length, 5, albumIds)]).toEqual([5]); + }); + + it('does not run away when missing-albumId neighbours sit between two same-album items', () => { + // Regression: previous artist-fallback code could grab unmatched neighbours. + // With a strict same-album-id match, a null between two A's stops expansion. + const albumIds = ['A', null, 'A']; + expect([...expandToAlbumBlock(albumIds.length, 0, albumIds)]).toEqual([0]); + }); +}); diff --git a/apps/mobile/src/logic/lib/__tests__/queueLogic.test.ts b/apps/mobile/src/logic/lib/__tests__/queueLogic.test.ts new file mode 100644 index 0000000..b4feb47 --- /dev/null +++ b/apps/mobile/src/logic/lib/__tests__/queueLogic.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { buildKeys, djBreakLabel, insertBeforeIndex, isPlayingRow, planSingleReorder, removeIndices, + resolveAttribution, selectionLabel, toggleSelected, toggleSelection, trackNumberForIndex, findPlayingIndex, playingPosition, scrollOffsetForPlaying, shouldAutoFollow, ROW_HEIGHT } from '../../../ui/queue/queueLogic'; +import { attributionContentKey } from '../../hooks/useAttribution'; + +const track = (id: string) => ({ index: 0, track: { id } as never }); + +describe('queueLogic', () => { + it('toggleSelection exits select mode when the last track is deselected', () => { + expect(toggleSelection(new Set([2]), 2)).toEqual({ selected: new Set(), selectMode: false }); + expect(toggleSelection(new Set([2]), 3)).toEqual({ selected: new Set([2, 3]), selectMode: true }); + expect(toggleSelection(new Set([2, 3]), 2)).toEqual({ selected: new Set([3]), selectMode: true }); + }); + it('maps drop position to insert-before', () => { + expect(insertBeforeIndex(1, 3)).toBe(4); + expect(insertBeforeIndex(3, 1)).toBe(1); + }); + it('planSingleReorder matches final positions', () => { + const items = ['A', 'B', 'C', 'D', 'E']; + expect(planSingleReorder(items, 1, 3).next).toEqual(['A', 'C', 'D', 'B', 'E']); + expect(planSingleReorder(items, 3, 1).next).toEqual(['A', 'D', 'B', 'C', 'E']); + expect(planSingleReorder(items, 0, 4)).toMatchObject({ toIndex: 5, fromIndices: [0], queueLength: 5 }); + }); + it('playing row uses 1-based queueItemId, else object id', () => { + expect(isPlayingRow(0, 'x', '1', null)).toBe(true); + expect(isPlayingRow(1, 'x', '1', 'x')).toBe(false); + expect(isPlayingRow(1, 'x', null, 'x')).toBe(true); + expect(isPlayingRow(1, 'x', null, null)).toBe(false); + }); + it('skip uses 1-based track number', () => expect(trackNumberForIndex(0)).toBe(1)); + it('toggles selection immutably', () => { + const s = new Set([1]); + expect([...toggleSelected(s, 2)]).toEqual([1, 2]); + expect([...toggleSelected(s, 1)]).toEqual([]); + expect(s.size).toBe(1); + }); + it('resolves attribution by id then content key', () => { + const e = { user: 'Ann' }; + const ck = attributionContentKey('Song', 'Band')!; + expect(resolveAttribution({ a: e }, { id: 'a', title: 'x', artist: 'y' })).toBe(e); + expect(resolveAttribution({ [ck]: e }, { id: 'zz', title: 'Song', artist: 'Band' })).toBe(e); + expect(resolveAttribution({}, { id: 'zz', title: 'Song', artist: 'Band' })).toBeUndefined(); + }); + it('builds unique stable keys for duplicates', () => { + expect(buildKeys([track('a'), track('b'), track('a')])).toEqual(['a#1', 'b#1', 'a#2']); + }); + it('removes indices and labels', () => { + expect(removeIndices(['a', 'b', 'c'], new Set([0, 2]))).toEqual(['b']); + expect(selectionLabel(1)).toBe('1 track selected'); + expect(selectionLabel(2)).toBe('2 tracks selected'); + }); + it('labels DJ toggle', () => { expect(djBreakLabel(true)).toBe('DJ takes over'); expect(djBreakLabel(false)).toBe('DJ paused'); }); +}); + +describe('scroll to now playing', () => { + const items = ['a', 'b', 'c', 'd'].map((id) => ({ track: { id } })); + + it('finds the playing row by 1-based queueItemId, else by objectId, else -1', () => { + expect(findPlayingIndex(items, '3', null)).toBe(2); + expect(findPlayingIndex(items, null, 'b')).toBe(1); + expect(findPlayingIndex(items, null, 'zzz')).toBe(-1); + expect(findPlayingIndex(items, '9', null)).toBe(-1); + expect(findPlayingIndex([], '1', null)).toBe(-1); + }); + + it('classifies the row against the viewport', () => { + const h = 5 * ROW_HEIGHT; // five rows visible + expect(playingPosition(-1, 0, h)).toBe('none'); + expect(playingPosition(2, 0, 0)).toBe('none'); // not measured yet + expect(playingPosition(2, 0, h)).toBe('visible'); + expect(playingPosition(4, 0, h)).toBe('visible'); // last visible row + expect(playingPosition(5, 0, h)).toBe('below'); + expect(playingPosition(3, 10 * ROW_HEIGHT, h)).toBe('above'); + expect(playingPosition(10, 10 * ROW_HEIGHT, h)).toBe('visible'); // exactly at the top edge + expect(playingPosition(9, 10 * ROW_HEIGHT, h)).toBe('above'); + }); + + it('a partly visible row still counts as visible', () => { + expect(playingPosition(1, ROW_HEIGHT / 2, 3 * ROW_HEIGHT)).toBe('visible'); + }); + + it('scrolls so one row of context stays above the playing row, never negative', () => { + expect(scrollOffsetForPlaying(10)).toBe(9 * ROW_HEIGHT); + expect(scrollOffsetForPlaying(1)).toBe(0); + expect(scrollOffsetForPlaying(0)).toBe(0); + expect(scrollOffsetForPlaying(10, 0)).toBe(10 * ROW_HEIGHT); + }); + + it('auto-follows only when following and the playing row really changed', () => { + expect(shouldAutoFollow(true, 3, 4)).toBe(true); + expect(shouldAutoFollow(false, 3, 4)).toBe(false); // user scrolled away + expect(shouldAutoFollow(true, 4, 4)).toBe(false); // unchanged + expect(shouldAutoFollow(true, 4, -1)).toBe(false); // nothing playing + }); +}); diff --git a/apps/mobile/src/logic/lib/__tests__/queuedleLogic.test.ts b/apps/mobile/src/logic/lib/__tests__/queuedleLogic.test.ts new file mode 100644 index 0000000..af35c3d --- /dev/null +++ b/apps/mobile/src/logic/lib/__tests__/queuedleLogic.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { bonusTarget, carryFromSide, distribution, londonDateToday, pipStates, projectRanking, scoreBonus, scoreMain, winningItems } from '../queuedleLogic'; + +const item = (id: string, topQueuer = 'Al'): GameItem => ({ + category: 'track', id, name: id, subtitle: '', count: 1, topQueuer, queuerCandidates: ['Al', 'Bo'], +}); +const q = (l: string, r: string, winner: 'left' | 'right', extra: Partial = {}): GameQuestion => ({ + index: 0, left: item(l, 'Al'), right: item(r, 'Bo'), winner, ...extra, +}); + +describe('queuedleLogic', () => { + const qs = [q('a', 'b', 'left'), q('a', 'c', 'right', { carryover: 'left', bonusItem: 'left' })]; + + it('scores main and bonus rounds', () => { + expect(scoreMain(qs, ['left', 'left'])).toBe(1); + expect(scoreBonus(qs, ['Al', 'Al'])).toBe(2); // q0 winner=a(Al), q1 bonusItem=left=a(Al) + expect(scoreBonus(qs, ['Bo', ''])).toBe(0); + }); + it('picks bonus targets (bonusItem overrides winner)', () => { + expect(bonusTarget(qs[1]).id).toBe('a'); + expect(winningItems(qs).map((i) => i.id)).toEqual(['a', 'a']); + }); + it('finds carry-over side', () => { + expect(carryFromSide(qs, 0)).toBeNull(); + expect(carryFromSide(qs, 1)).toBe('left'); + }); + it('computes pip states', () => { + expect(pipStates(qs, 0, false, [])).toEqual(['current', 'pending']); + expect(pipStates(qs, 1, false, ['right'])).toEqual(['wrong', 'current']); + expect(pipStates(qs, 1, true, ['left', 'right'])).toEqual(['correct', 'correct']); + }); + it('projects a first-game ranking as provisional', () => { + const r = projectRanking(null, 3, 5); + expect(r.gamesPlayed).toBe(1); + expect(r.isProvisional).toBe(true); + expect(r.averagePercent).toBeCloseTo(30); + }); + it('buckets distributions', () => { + const d = distribution([0, 1, 1, 9], 3); + expect(d).toHaveLength(4); + expect(d[1].pct).toBe(50); + expect(distribution([], 2).every((b) => b.pct === 0)).toBe(true); + }); + it('formats London date', () => { + expect(londonDateToday(new Date('2026-01-05T12:00:00Z'))).toBe('2026-01-05'); + expect(londonDateToday(new Date('2026-06-30T23:30:00Z'))).toBe('2026-07-01'); + }); +}); diff --git a/apps/mobile/src/logic/lib/__tests__/recentSearches.test.ts b/apps/mobile/src/logic/lib/__tests__/recentSearches.test.ts new file mode 100644 index 0000000..89a666a --- /dev/null +++ b/apps/mobile/src/logic/lib/__tests__/recentSearches.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import type { KeyValueStore } from '../../../office/config'; +import { addRecent, clearRecent, loadRecent, MAX_RECENT, normaliseRecent, RECENT_SEARCHES_KEY, removeRecent } from '../recentSearches'; + +function memStore(initial?: string): KeyValueStore & { data: Map } { + const data = new Map(); + if (initial !== undefined) data.set(RECENT_SEARCHES_KEY, initial); + return { + data, + get: async (k) => data.get(k) ?? null, + set: async (k, v) => void data.set(k, v), + remove: async (k) => void data.delete(k), + }; +} + +describe('recentSearches', () => { + it('loads empty when nothing stored', async () => { + expect(await loadRecent(memStore())).toEqual([]); + }); + it('tolerates invalid JSON and wrong shapes', async () => { + expect(await loadRecent(memStore('{oops'))).toEqual([]); + expect(await loadRecent(memStore('{"a":1}'))).toEqual([]); + expect(await loadRecent(memStore('null'))).toEqual([]); + expect(await loadRecent(memStore('["a",3,null," ","b"]'))).toEqual(['a', 'b']); + }); + it('tolerates a throwing store', async () => { + const boom = async () => { + throw new Error('x'); + }; + const bad: KeyValueStore = { get: boom, set: boom, remove: boom }; + expect(await loadRecent(bad)).toEqual([]); + expect(await addRecent(bad, 'abba')).toEqual(['abba']); + }); + it('adds most-recent-first and trims', async () => { + const s = memStore(); + await addRecent(s, ' abba '); + expect(await addRecent(s, 'queen')).toEqual(['queen', 'abba']); + expect(JSON.parse(s.data.get(RECENT_SEARCHES_KEY)!)).toEqual(['queen', 'abba']); + }); + it('ignores blank queries', async () => { + const s = memStore(); + expect(await addRecent(s, ' ')).toEqual([]); + expect(s.data.size).toBe(0); + }); + it('dedupes case-insensitively, moving to front with latest casing', async () => { + const s = memStore(); + await addRecent(s, 'Abba'); + await addRecent(s, 'queen'); + expect(await addRecent(s, 'ABBA')).toEqual(['ABBA', 'queen']); + }); + it('collapses inner whitespace', async () => { + expect(await addRecent(memStore(), 'a b')).toEqual(['a b']); + }); + it('caps at MAX_RECENT dropping oldest', async () => { + const s = memStore(); + for (let i = 0; i < 15; i++) await addRecent(s, `q${i}`); + const list = await loadRecent(s); + expect(list).toHaveLength(MAX_RECENT); + expect(list[0]).toBe('q14'); + expect(list[MAX_RECENT - 1]).toBe('q5'); + }); + it('normalise caps oversized stored lists', () => { + expect(normaliseRecent(Array.from({ length: 30 }, (_, i) => `x${i}`))).toHaveLength(MAX_RECENT); + }); + it('removes case-insensitively', async () => { + const s = memStore(JSON.stringify(['a', 'B', 'c'])); + expect(await removeRecent(s, 'b')).toEqual(['a', 'c']); + expect(await removeRecent(s, 'zzz')).toEqual(['a', 'c']); + }); + it('clear removes the key', async () => { + const s = memStore(JSON.stringify(['a'])); + expect(await clearRecent(s)).toEqual([]); + expect(s.data.has(RECENT_SEARCHES_KEY)).toBe(false); + }); + it('removing the last entry removes the key', async () => { + const s = memStore(JSON.stringify(['a'])); + await removeRecent(s, 'a'); + expect(s.data.has(RECENT_SEARCHES_KEY)).toBe(false); + }); +}); diff --git a/apps/mobile/src/logic/lib/__tests__/socialFormat.test.ts b/apps/mobile/src/logic/lib/__tests__/socialFormat.test.ts new file mode 100644 index 0000000..080701e --- /dev/null +++ b/apps/mobile/src/logic/lib/__tests__/socialFormat.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { activeLyricIndex, avatarColor, barPercent, feedbackIssueUrl, lyricOpacity, plural, statsFooter, tierEmoji } from '../socialFormat'; + +const lines = [ + { timeMs: 1000, text: 'a' }, + { timeMs: 5000, text: 'b' }, + { timeMs: 9000, text: 'c' }, +]; + +describe('socialFormat', () => { + it('finds the active lyric line', () => { + expect(activeLyricIndex(lines, 0)).toBe(-1); + expect(activeLyricIndex(lines, 1000)).toBe(0); + expect(activeLyricIndex(lines, 6000)).toBe(1); + expect(activeLyricIndex(lines, 99999)).toBe(2); + expect(activeLyricIndex([], 5)).toBe(-1); + }); + it('fades lyric lines by distance', () => { + expect(lyricOpacity(3, -1)).toBe(1); + expect(lyricOpacity(2, 2)).toBe(1); + expect(lyricOpacity(3, 2)).toBeCloseTo(0.81); + expect(lyricOpacity(30, 2)).toBe(0.18); + }); + it('is deterministic for avatars', () => { + expect(avatarColor('Rich')).toBe(avatarColor('Rich')); + expect(avatarColor('Rich')).toMatch(/^hsl\(/); + }); + it('pluralises and formats footers', () => { + expect(plural(1, 'game')).toBe('1 game'); + expect(plural(2, 'game')).toBe('2 games'); + expect(statsFooter(1, 0, 'Bob')).toBe('1 queue event by Bob total'); + expect(statsFooter(3, 0)).toBe('3 queue events total'); + expect(statsFooter(3, Date.now())).toContain('since'); + }); + it('computes bar percent safely', () => { + expect(barPercent(5, 10)).toBe(50); + expect(barPercent(5, 0)).toBe(0); + expect(barPercent(50, 10)).toBe(100); + }); + it('maps tiers and builds issue urls', () => { + expect(tierEmoji('playlist-prophet')).toBe('🏆'); + expect(tierEmoji('nope')).toBe('🎵'); + const u = feedbackIssueUrl('bug', ' T ', 'desc', 'Bob'); + expect(u).toContain('issues/new?title=T'); + expect(u).toContain('labels=bug'); + expect(feedbackIssueUrl('feature', 't', 'd')).toContain('labels=enhancement'); + }); +}); diff --git a/apps/mobile/src/logic/lib/__tests__/sonosApi.test.ts b/apps/mobile/src/logic/lib/__tests__/sonosApi.test.ts new file mode 100644 index 0000000..4b7a851 --- /dev/null +++ b/apps/mobile/src/logic/lib/__tests__/sonosApi.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { api } from '../sonosApi'; + +const mockFetch = vi.mocked(window.sonos.fetch); + +beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue({ data: null }); +}); + +// ─── defined() filtering (tested via observable API behaviour) ──────────────── + +describe('defined() param filtering', () => { + it('omits undefined pathParams instead of passing them as "undefined" strings', async () => { + await api.playback.getState(); // groupId not provided + const call = mockFetch.mock.calls[0][0]; + expect(call.pathParams).not.toHaveProperty('groupId'); + }); + + it('includes defined pathParams', async () => { + await api.playback.getState('g:abc'); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ pathParams: { groupId: 'g:abc' } }) + ); + }); +}); + +// ─── auth ──────────────────────────────────────────────────────────────────── + +describe('api.auth', () => { + it('getDiscovery uses correct operationId', async () => { + await api.auth.getDiscovery(); + expect(mockFetch).toHaveBeenCalledWith({ operationId: 'getAuthDiscovery' }); + }); +}); + +// ─── groups ────────────────────────────────────────────────────────────────── + +describe('api.groups', () => { + it('list uses getGroups operationId', async () => { + await api.groups.list(); + expect(mockFetch).toHaveBeenCalledWith({ operationId: 'getGroups' }); + }); +}); + +// ─── playback ──────────────────────────────────────────────────────────────── + +describe('api.playback', () => { + it('play sends correct operationId with groupId', async () => { + await api.playback.play('g:1'); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ operationId: 'play', pathParams: { groupId: 'g:1' } }) + ); + }); + + it('pause sends correct operationId', async () => { + await api.playback.pause('g:1'); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ operationId: 'pause' }) + ); + }); + + it('skipNext sends skipToNextTrack operationId', async () => { + await api.playback.skipNext('g:1'); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ operationId: 'skipToNextTrack' }) + ); + }); + + it('setPlayMode sends body with mode flags', async () => { + await api.playback.setPlayMode({ shuffle: true, repeat: false }, 'g:1'); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'setPlayMode', + body: { shuffle: true, repeat: false }, + }) + ); + }); +}); + +// ─── queue ─────────────────────────────────────────────────────────────────── + +describe('api.queue', () => { + it('list converts count and offset to strings', async () => { + await api.queue.list({ count: 50, offset: 100 }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'getQueueResources', + query: { count: '50', offset: '100' }, + }) + ); + }); + + it('list omits undefined count/offset from query', async () => { + await api.queue.list({}); + const call = mockFetch.mock.calls[0][0]; + expect(call.query).not.toHaveProperty('count'); + expect(call.query).not.toHaveProperty('offset'); + }); + + it('add defaults position to -1 when not provided', async () => { + await api.queue.add({}); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ query: { position: '-1' } }) + ); + }); + + it('add uses provided position', async () => { + await api.queue.add({}, { position: 3 }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ query: { position: '3' } }) + ); + }); + + it('add includes If-Match header when ifMatch provided', async () => { + await api.queue.add({}, { ifMatch: 'abc123' }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ headers: { 'If-Match': 'abc123' } }) + ); + }); + + it('add does not include headers when ifMatch absent', async () => { + await api.queue.add({}); + const call = mockFetch.mock.calls[0][0]; + expect(call.headers).toBeUndefined(); + }); + + it('remove joins item indices with commas', async () => { + await api.queue.remove([0, 2, 5]); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ query: { items: '0,2,5' } }) + ); + }); + + it('reorder sends items and positions joined', async () => { + await api.queue.reorder([1, 3], [0, 2], []); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'reorderQueueResources', + query: { items: '1,3', positions: '0,2' }, + }) + ); + }); +}); + +// ─── browse ────────────────────────────────────────────────────────────────── + +describe('api.browse', () => { + it('album sends albumId as pathParam and count as string', async () => { + await api.browse.album('alb-1', { serviceId: 'gm', count: 25 }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'browseAlbum', + pathParams: expect.objectContaining({ albumId: 'alb-1', serviceId: 'gm' }), + query: expect.objectContaining({ count: '25' }), + }) + ); + }); + + it('album includes muse2=true as string when requested', async () => { + await api.browse.album('alb-1', { muse2: true }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ query: expect.objectContaining({ muse2: 'true' }) }) + ); + }); + + it('album omits muse2 query param when false', async () => { + await api.browse.album('alb-1', { muse2: false }); + const call = mockFetch.mock.calls[0][0]; + expect(call.query).not.toHaveProperty('muse2'); + }); + + it('artist sends artistId as pathParam', async () => { + await api.browse.artist('art-1', { serviceId: 'gm', accountId: 'acc1' }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'browseArtist', + pathParams: expect.objectContaining({ artistId: 'art-1' }), + }) + ); + }); + + it('container sends containerId as pathParam', async () => { + await api.browse.container('c-1'); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'browseContainer', + pathParams: expect.objectContaining({ containerId: 'c-1' }), + }) + ); + }); +}); + +// ─── search ────────────────────────────────────────────────────────────────── + +describe('api.search', () => { + it('serviceQuery sends query string and count as string', async () => { + await api.search.serviceQuery('Beatles', { serviceId: 'gm', count: 50 }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'searchService', + query: expect.objectContaining({ query: 'Beatles', count: '50' }), + }) + ); + }); + + it('query joins services array with commas', async () => { + await api.search.query('jazz', ['gm', 'sp']); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ query: expect.objectContaining({ services: 'gm,sp' }) }) + ); + }); + + it('query omits services when not provided', async () => { + await api.search.query('jazz'); + const call = mockFetch.mock.calls[0][0]; + expect(call.query).not.toHaveProperty('services'); + }); +}); + +// ─── playback — remaining methods ──────────────────────────────────────────── + +describe('api.playback — remaining', () => { + it('getState sends getPlaybackState operationId', async () => { + await api.playback.getState('g:1'); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ operationId: 'getPlaybackState', pathParams: { groupId: 'g:1' } }) + ); + }); + + it('skipPrev sends skipToPreviousTrack operationId', async () => { + await api.playback.skipPrev('g:1'); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ operationId: 'skipToPreviousTrack' }) + ); + }); +}); + +// ─── browse — remaining methods ─────────────────────────────────────────────── + +describe('api.browse — remaining', () => { + it('artist sends artistId as pathParam', async () => { + await api.browse.artist('art-1', { serviceId: 'gm', accountId: 'acc1' }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'browseArtist', + pathParams: expect.objectContaining({ artistId: 'art-1', serviceId: 'gm' }), + }) + ); + }); + + it('container sends containerId as pathParam', async () => { + await api.browse.container('c-1', { serviceId: 'gm' }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'browseContainer', + pathParams: expect.objectContaining({ containerId: 'c-1' }), + }) + ); + }); + + it('playlist sends playlistId as pathParam', async () => { + await api.browse.playlist('pl-1', { serviceId: 'gm', count: 50 }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'browsePlaylist', + pathParams: expect.objectContaining({ playlistId: 'pl-1' }), + query: expect.objectContaining({ count: '50' }), + }) + ); + }); + + it('catalogContainer sends containerId as pathParam', async () => { + await api.browse.catalogContainer('cat-1', { serviceId: 'gm', count: 10 }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'getCatalogContainerResources', + pathParams: expect.objectContaining({ containerId: 'cat-1' }), + query: expect.objectContaining({ count: '10' }), + }) + ); + }); +}); + +// ─── content ───────────────────────────────────────────────────────────────── + +describe('api.content', () => { + it('favorites sends getFavoriteResources operationId with count', async () => { + await api.content.favorites({ count: 20 }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'getFavoriteResources', + query: expect.objectContaining({ count: '20' }), + }) + ); + }); + + it('history sends getHistory operationId with count', async () => { + await api.content.history({ count: 20 }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'getHistory', + query: expect.objectContaining({ count: '20' }), + }) + ); + }); +}); + +// ─── integrations ──────────────────────────────────────────────────────────── + +describe('api.integrations', () => { + it('list sends getIntegrations operationId', async () => { + await api.integrations.list(); + expect(mockFetch).toHaveBeenCalledWith({ operationId: 'getIntegrations' }); + }); + + it('registrations sends getIntegrationRegistrations operationId', async () => { + await api.integrations.registrations(); + expect(mockFetch).toHaveBeenCalledWith({ operationId: 'getIntegrationRegistrations' }); + }); +}); + +// ─── nowPlaying ────────────────────────────────────────────────────────────── + +describe('api.nowPlaying', () => { + it('track sends trackId as pathParam', async () => { + await api.nowPlaying.track('trk-1', { serviceId: 'gm', accountId: 'acc1' }); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: 'getTrackNowPlaying', + pathParams: expect.objectContaining({ trackId: 'trk-1' }), + }) + ); + }); +}); + +// ─── platform ──────────────────────────────────────────────────────────────── + +describe('api.platform', () => { + it('mfe sends getMfe operationId', async () => { + await api.platform.mfe(); + expect(mockFetch).toHaveBeenCalledWith({ operationId: 'getMfe' }); + }); + + it('featureFlag sends key as pathParam', async () => { + await api.platform.featureFlag('my-flag'); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ operationId: 'getOptimizelyConfig', pathParams: { key: 'my-flag' } }) + ); + }); + + it('metrics sends events array as body', async () => { + await api.platform.metrics([{ type: 'click' }]); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ operationId: 'postMetrics', body: [{ type: 'click' }] }) + ); + }); +}); diff --git a/apps/mobile/src/logic/lib/avatarImage.ts b/apps/mobile/src/logic/lib/avatarImage.ts new file mode 100644 index 0000000..1eb4842 --- /dev/null +++ b/apps/mobile/src/logic/lib/avatarImage.ts @@ -0,0 +1,37 @@ +// Normalize an uploaded avatar before it goes to blob storage. +// Desktop center-crops to 512px WebP via canvas. In RN the UI layer plugs a +// resizer in with setImageResizer (TODO: expo-image-manipulator); default is pass-through. + +export interface NormalizedImage { + buffer: ArrayBuffer; + mimeType: string; +} + +/** Minimal file shape (browser File satisfies this; RN callers can supply their own). */ +export interface AvatarFile { + type: string; + arrayBuffer(): Promise; +} + +export type ImageResizer = (file: AvatarFile) => Promise; + +const passthrough: ImageResizer = async (file) => ({ + buffer: await file.arrayBuffer(), + mimeType: file.type, +}); + +let resizer: ImageResizer = passthrough; + +export function setImageResizer(fn: ImageResizer | null): void { + resizer = fn ?? passthrough; +} + +export async function normalizeAvatar(file: AvatarFile): Promise { + // Leave GIFs untouched so animation is preserved. + if (file.type === 'image/gif') return passthrough(file); + try { + return await resizer(file); + } catch { + return passthrough(file); + } +} diff --git a/apps/mobile/src/logic/lib/itemHelpers.ts b/apps/mobile/src/logic/lib/itemHelpers.ts new file mode 100644 index 0000000..38fb61b --- /dev/null +++ b/apps/mobile/src/logic/lib/itemHelpers.ts @@ -0,0 +1,367 @@ +import type { SonosItem, SonosItemId, QueueItem, SonosTrack } from '../types/sonos'; +import type { ServiceSearch } from '../types/ServiceSearch'; +import type { NormalizedQueueItem } from '../types/provider'; + +// ─── Defaults decoder ───────────────────────────────────────────────────────── + +export function decodeDefaults(defaults: string | undefined): Record | null { + if (!defaults) return null; + try { return JSON.parse(atob(defaults)); } catch { return null; } +} + +// ─── Optimistic queue normaliser ───────────────────────────────────────────── +// +// Raw browse/album track items have `id` as a string SRN and `images` as +// `{ tile1x1 }` rather than an array. useQueueTrack needs an object `id` to +// extract the trackId for the nowPlaying query, and getArt needs `imageUrl` or +// an images array. This function converts browse-shaped items into the shape +// that the queue row renderer expects. + +export function normalizeForQueue(item: SonosItem): SonosItem { + const rid = item.resource?.id as SonosItemId | undefined; + // If id is already an object the item is already normalised (e.g. search results) + if (typeof item.id === 'object' || !rid?.objectId) return item; + + const imageUrl = + (item.images as Record | undefined)?.['tile1x1'] ?? item.imageUrl; + const artistName = (item as Record)['subtitle'] ?? ''; + + return { + ...item, + id: rid, + track: { + id: rid, + name: item.title ?? item.name, + artist: artistName ? { name: artistName } : undefined, + imageUrl, + images: imageUrl ? [{ url: imageUrl }] : undefined, + }, + }; +} + +// ─── Album param resolver ───────────────────────────────────────────────────── + +export function resolveAlbumParams(item: SonosItem) { + const rid = item.resource?.id as SonosItemId | undefined; + const iid = typeof item.id === 'object' ? item.id as SonosItemId : undefined; + return { + albumId: rid?.objectId ?? iid?.objectId, + serviceId: rid?.serviceId ?? iid?.serviceId, + accountId: (rid?.accountId ?? iid?.accountId)?.replace(/^sn_/, ''), + defaults: item.resource?.defaults as string | undefined, + }; +} + +// ─── Response normalisation ─────────────────────────────────────────────────── + +export function extractItems(data: unknown): SonosItem[] { + if (!data) return []; + const d = data as Record; + if (Array.isArray(data)) return data as SonosItem[]; + if (Array.isArray(d['items'])) return d['items'] as SonosItem[]; + if (Array.isArray(d['resources'])) return d['resources'] as SonosItem[]; + // Container browse: { section: { items: [...] } } + const sec = d['section'] as Record | undefined; + if (sec && Array.isArray(sec['items'])) return sec['items'] as SonosItem[]; + if (Array.isArray(d['services'])) { + const flat: SonosItem[] = []; + for (const svc of d['services'] as Record[]) { + const r = (svc['results'] ?? svc) as Record; + for (const key of Object.keys(r)) { + if (Array.isArray(r[key])) { + (r[key] as SonosItem[]).forEach(item => { + flat.push({ ...item, _svcId: svc['serviceId'] } as SonosItem); + }); + } + } + } + return flat; + } + return []; +} + +// ─── Field extractors ───────────────────────────────────────────────────────── + +export function getName(item: SonosItem): string { + return item?.name ?? item?.title ?? item?.resource?.name ?? item?.track?.name ?? '(unknown)'; +} + +function resolveArtistName(raw: unknown): string { + if (!raw) return ''; + if (typeof raw === 'object' && raw !== null) { + return (raw as { name?: string }).name ?? ''; + } + return String(raw); +} + +export function getSub(item: SonosItem): string { + // subtitle is used by container browse responses (e.g. album items have subtitle = artist name) + const raw = item?.artist ?? item?.primaryArtist ?? (item as Record)?.['subtitle'] + ?? item?.resource?.artist ?? item?.track?.artist ?? item?.description ?? ''; + const result = resolveArtistName(raw); + return item?.summary?.content ?? result; +} + +export function getArtist(item: QueueItem): string { + return resolveArtistName(item?.track?.artist ?? item?.artist ?? item?.primaryArtist ?? ''); +} + +// Sonos serves multi-artist credits as a single joined string ("Sonny Stitt, Kenny Garrett"). +export function splitArtists(artist: string): string[] { + return artist.split(/,\s*/).map((s) => s.trim()).filter(Boolean); +} + +export function getAlbum(item: QueueItem): string { + const raw = item?.track?.album ?? item?.album ?? ''; + if (!raw) return ''; + if (typeof raw === 'object' && raw !== null) { + return (raw as { name?: string }).name ?? ''; + } + return String(raw); +} + +export function getArt(item: SonosItem): string | null { + return item?.track?.imageUrl + ?? item?.track?.images?.[0]?.url + ?? item?.imageUrl + ?? item?.images?.[0]?.url + ?? null; +} + +/** Like getArt but also handles browse API items where images is { tile1x1 } not an array. */ +export function getItemArt(item: SonosItem): string | null { + return (item.images as Record | undefined)?.['tile1x1'] ?? getArt(item) ?? null; +} + +export function isAlbum(item: SonosItem): boolean { + const type = (item.resource?.type ?? item.type ?? '') as string; + return type.toUpperCase().includes('ALBUM'); +} + +export function isPlaylist(item: SonosItem): boolean { + const type = (item.resource?.type ?? item.type ?? '') as string; + const t = type.toUpperCase(); + // ITEM_PLAYLIST is a container browse item, not a directly-browseable playlist + return t.includes('PLAYLIST') && !t.startsWith('ITEM_'); +} + +export function isArtist(item: SonosItem): boolean { + const type = (item.resource?.type ?? item.type ?? '') as string; + return type.toUpperCase().includes('ARTIST'); +} + +export function isTrack(item: SonosItem): boolean { + const type = (item.resource?.type ?? item.type ?? '') as string; + return type.toUpperCase().includes('TRACK'); +} + +export function isContainer(item: SonosItem): boolean { + const type = (item.resource?.type ?? item.type ?? '') as string; + const t = type.toUpperCase(); + return t === 'ITEM_AUDIO' || t === 'ITEM_STUDIO' || t === 'ITEM_PLAYLIST' + || t === 'CONTAINER' || t === 'SECTION'; +} + +/** Items played via WS loadContent (stations, radio mixes, programs). */ +export function isProgram(item: SonosItem): boolean { + const type = (item.resource?.type ?? item.type ?? '') as string; + return type.toUpperCase() === 'PROGRAM'; +} + +const TYPE_LABELS: Record = { + TRACK: 'Track', + ALBUM: 'Album', + ARTIST: 'Artist', + PLAYLIST: 'Playlist', + PODCAST: 'Podcast', + PROGRAM: 'Station', + AUDIO: 'Collection', + STUDIO: 'Studio Mix', + CONTAINER: '', + SECTION: '', +}; + +function formatItemType(raw: string | undefined): string { + if (!raw) return ''; + const key = raw.toUpperCase().replace(/^ITEM_/, ''); + return TYPE_LABELS[key] ?? ''; +} + +export function browseSub(item: SonosItem): string { + if (item?.summary?.content) return item.summary.content; + const type = formatItemType(item?.type); + const artists = item?.artists?.map((a) => a.name).join(', ') ?? getSub(item); + return [type, artists].filter(Boolean).join(' \u2022 '); +} + +// ─── Artist param resolver ──────────────────────────────────────────────────── + +export function resolveArtistParams(item: SonosItem) { + const rid = item.resource?.id as SonosItemId | undefined; + const iid = typeof item.id === 'object' ? item.id as SonosItemId : undefined; + const serviceId = rid?.serviceId ?? iid?.serviceId; + const accountId = (rid?.accountId ?? iid?.accountId)?.replace(/^sn_/, ''); + + // Direct ARTIST-type item + if (item.resource?.type === 'ARTIST' || item.type === 'ARTIST') { + return { + artistId: rid?.objectId ?? iid?.objectId, + serviceId, + accountId, + defaults: item.resource?.defaults as string | undefined, + name: (item.title ?? item.name) as string | undefined, + }; + } + + // Album/track item: extract artistId from decoded defaults + const decoded = decodeDefaults(item.resource?.defaults as string | undefined); + if (decoded?.['artistId']) { + return { + artistId: decoded['artistId'] as string, + serviceId, + accountId, + defaults: undefined as string | undefined, + name: decoded['artist'] as string | undefined, + }; + } + + return { artistId: undefined, serviceId, accountId, defaults: undefined, name: undefined }; +} + +// ─── ServiceSearch normaliser ───────────────────────────────────────────────── + +export function parseServiceSearch(data: ServiceSearch): SonosItem[] { + const order = data.resourceOrder ?? ['ARTISTS', 'ALBUMS', 'TRACKS']; + const result: SonosItem[] = []; + + for (const section of order) { + switch (section) { + case 'ARTISTS': + for (const r of data.ARTISTS?.resources ?? []) { + result.push({ + type: 'ARTIST', + name: r.name, + images: r.images, + summary: { content: r.bio?.content ?? '' }, + id: { objectId: r.id.objectId, serviceId: r.id.serviceId, accountId: r.id.accountId }, + resource: { + type: 'ARTIST', + id: { objectId: r.id.objectId, serviceId: r.id.serviceId, accountId: r.id.accountId }, + }, + }); + } + break; + case 'ALBUMS': + for (const r of data.ALBUMS?.resources ?? []) { + result.push({ + type: 'ALBUM', + name: r.name, + images: r.images, + artists: r.artists?.map(a => ({ name: a.name, id: a.id })), + explicit: r.explicit, + summary: r.summary, + id: { objectId: r.id.objectId, serviceId: r.id.serviceId, accountId: r.id.accountId }, + resource: { + type: 'ALBUM', + id: { objectId: r.id.objectId, serviceId: r.id.serviceId, accountId: r.id.accountId }, + }, + }); + } + break; + case 'TRACKS': + for (const r of data.TRACKS?.resources ?? []) { + result.push({ + type: 'TRACK', + name: r.name, + images: r.images, + artists: r.artists?.map(a => ({ name: a.name, id: a.id })), + explicit: r.explicit, + durationMs: r.durationMs, + id: { objectId: r.id.objectId, serviceId: r.id.serviceId, accountId: r.id.accountId }, + resource: { + type: 'TRACK', + id: { objectId: r.id.objectId, serviceId: r.id.serviceId, accountId: r.id.accountId }, + }, + }); + } + break; + case 'PLAYLISTS': + for (const r of data.PLAYLISTS?.resources ?? []) { + result.push({ + type: 'PLAYLIST', + name: r.name, + images: r.images, + id: { objectId: r.id.objectId, serviceId: r.id.serviceId, accountId: r.id.accountId }, + resource: { + type: 'PLAYLIST', + id: { objectId: r.id.objectId, serviceId: r.id.serviceId, accountId: r.id.accountId }, + }, + }); + } + break; + case 'PODCASTS': + for (const r of data.PODCASTS?.resources ?? []) { + result.push({ + type: 'PODCAST', + name: r.name, + images: r.images, + id: { objectId: r.id.objectId, serviceId: r.id.serviceId, accountId: r.id.accountId }, + resource: { + type: 'PODCAST', + id: { objectId: r.id.objectId, serviceId: r.id.serviceId, accountId: r.id.accountId }, + }, + }); + } + break; + } + } + + return result; +} + +// ─── Sonos→Normalized queue item converter (for optimistic inserts) ────────── + +export function sonosItemToNormalizedQueueItem(item: SonosItem, index: number): NormalizedQueueItem { + const track = item.track as SonosTrack | undefined; + const id = (track?.id ?? (typeof item.id === 'object' ? item.id : undefined)) as SonosItemId | undefined; + const albumObj = track?.album as { name?: string; id?: { objectId?: string } } | undefined; + const rawArtist = track?.artist ?? track?.primaryArtist ?? item.artist ?? item.primaryArtist; + const artistName = rawArtist + ? typeof rawArtist === 'object' ? (rawArtist as { name?: string }).name ?? '' : String(rawArtist) + : ''; + const artUrls = [ + ...(track?.images?.map((i: { url: string }) => i.url) ?? []), + track?.imageUrl, + item.imageUrl, + ].filter((u): u is string => !!u); + const imageUrl = artUrls.find((u) => u.startsWith('https://')) ?? null; + + return { + index, + track: { + id: id?.objectId ?? '', + title: track?.name ?? track?.title ?? item.name ?? item.title ?? '', + artist: artistName, + albumName: albumObj?.name ?? null, + albumId: albumObj?.id?.objectId ?? null, + imageUrl, + durationMs: track?.durationMillis ?? 0, + isExplicit: !!(track?.explicit ?? (track as Record | undefined)?.['isExplicit']), + serviceId: id?.serviceId ?? null, + accountId: id?.accountId ?? null, + }, + }; +} + +// ─── Formatting ─────────────────────────────────────────────────────────────── + +export function fmtDuration(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${String(s).padStart(2, '0')}`; +} + +export function fmtTime(ms: number): string { + const s = Math.floor(ms / 1000); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +} diff --git a/apps/mobile/src/logic/lib/playlistColor.ts b/apps/mobile/src/logic/lib/playlistColor.ts new file mode 100644 index 0000000..4d9069a --- /dev/null +++ b/apps/mobile/src/logic/lib/playlistColor.ts @@ -0,0 +1,9 @@ +export function getPlaylistColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + hash |= 0; + } + const hue = Math.abs(hash) % 360; + return `linear-gradient(135deg, hsl(${hue},45%,28%), hsl(${(hue + 50) % 360},50%,20%))`; +} diff --git a/apps/mobile/src/logic/lib/prefetch.ts b/apps/mobile/src/logic/lib/prefetch.ts new file mode 100644 index 0000000..6e238c1 --- /dev/null +++ b/apps/mobile/src/logic/lib/prefetch.ts @@ -0,0 +1,75 @@ +// Pure helpers for warming data/art ahead of use (queue art, upcoming tracks). + +export interface QueueLikeItem { + track: { id: string; serviceId: string | null; accountId: string | null }; +} + +/** Index of the playing item: by objectId, else the 1-based queueItemId cursor, else 0. */ +export function currentQueueIndex( + items: QueueLikeItem[], + currentObjectId: string | null, + queueItemId: string | null, +): number { + if (currentObjectId) { + const byId = items.findIndex((i) => i.track.id === currentObjectId); + if (byId >= 0) return byId; + } + const n = Number(queueItemId); + if (Number.isInteger(n) && n >= 1 && n <= items.length) return n - 1; + return 0; +} + +/** + * The items worth warming, in priority order: what plays next first (that is what the user will see + * and tap soonest), then the tracks just behind, then the top of the list they scroll from. + * De-duplicated, clamped to the queue, and skipping items that lack the ids a lookup needs. + */ +export function prefetchWindow(items: T[], currentIndex: number, ahead = 8, behind = 2, head = 6): T[] { + const order: number[] = []; + const seen = new Set(); + const push = (i: number) => { + if (i >= 0 && i < items.length && !seen.has(i)) { + seen.add(i); + order.push(i); + } + }; + for (let i = currentIndex; i <= currentIndex + ahead; i++) push(i); + for (let i = currentIndex - 1; i >= currentIndex - behind; i--) push(i); + for (let i = 0; i < head; i++) push(i); + return order.map((i) => items[i]).filter((it) => it.track.id && it.track.serviceId && it.track.accountId); +} + +/** Run async tasks with a concurrency cap. Never rejects; a failing task is skipped. */ +export async function runLimited(tasks: Array<() => Promise>, concurrency: number, isCancelled: () => boolean = () => false): Promise { + let next = 0; + const worker = async () => { + while (!isCancelled()) { + const i = next++; + if (i >= tasks.length) return; + try { + await tasks[i](); + } catch { + /* best effort */ + } + } + }; + await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, tasks.length)) }, worker)); +} + +/** A bounded "already requested" set, so a long session doesn't grow without limit. */ +export function createSeen(max = 2000) { + const seen = new Set(); + return { + /** True the first time a key is added, false if it was already present. */ + add(key: string): boolean { + if (seen.has(key)) return false; + if (seen.size >= max) seen.delete(seen.values().next().value as string); + seen.add(key); + return true; + }, + has: (key: string) => seen.has(key), + get size() { + return seen.size; + }, + }; +} diff --git a/apps/mobile/src/logic/lib/queryPersistence.ts b/apps/mobile/src/logic/lib/queryPersistence.ts new file mode 100644 index 0000000..a549962 --- /dev/null +++ b/apps/mobile/src/logic/lib/queryPersistence.ts @@ -0,0 +1,18 @@ +// What to keep across launches. Track details (art URL, artist, album ids) never change for a +// given track, and they are what queue art is derived from, so persisting them makes the next +// launch show art immediately instead of re-fetching one Sonos request per row. + +export const PERSIST_MAX_AGE = 7 * 24 * 60 * 60 * 1000; + +/** Bump to discard every previously persisted cache (e.g. if the TrackDetails shape changes). */ +export const CACHE_BUSTER = 'track-details-v1'; + +interface DehydratableQuery { + queryKey: readonly unknown[]; + state: { status: string }; +} + +/** Only successfully loaded track-detail queries; everything else (search, browse, queue) stays live. */ +export function shouldPersistQuery(query: DehydratableQuery): boolean { + return query.queryKey[0] === 'track' && typeof query.queryKey[1] === 'string' && query.state.status === 'success'; +} diff --git a/apps/mobile/src/logic/lib/queueHelpers.ts b/apps/mobile/src/logic/lib/queueHelpers.ts new file mode 100644 index 0000000..880f775 --- /dev/null +++ b/apps/mobile/src/logic/lib/queueHelpers.ts @@ -0,0 +1,38 @@ +export function applyReorderLocally(items: T[], fromIndices: number[], toIndex: number): T[] { + const selectedSet = new Set(fromIndices); + const remaining = items.filter((_, i) => !selectedSet.has(i)); + const origNonSelected = items.flatMap((_, i) => (selectedSet.has(i) ? [] : [i])); + const insertAt = origNonSelected.filter(i => i < toIndex).length; + const movers = [...fromIndices].sort((a, b) => a - b).map(i => items[i]); + return [...remaining.slice(0, insertAt), ...movers, ...remaining.slice(insertAt)]; +} + +// Returns the indices of the contiguous run of queue items that share the anchor's +// album id. Always includes the anchor. If the anchor has no resolved album id, or +// no adjacent neighbour matches, returns just the anchor (no artist fallback — +// matching by artist alone tends to grab the entire queue when every track has the +// same artist or empty/unresolved artist). +// +// `albumIds` is aligned to the queue items. Callers must supply the merged album id +// (e.g. NormalizedTrack values overlaid with whatever `useTrackDetails` resolved) +// because Sonos doesn't reliably embed album info on raw queue rows. +export function expandToAlbumBlock( + itemCount: number, + anchor: number, + albumIds: (string | null)[], +): Set { + if (anchor < 0 || anchor >= itemCount) return new Set([anchor]); + const anchorAlbum = albumIds[anchor]; + if (!anchorAlbum) return new Set([anchor]); + + const result = new Set([anchor]); + for (let i = anchor - 1; i >= 0; i--) { + if (albumIds[i] !== anchorAlbum) break; + result.add(i); + } + for (let i = anchor + 1; i < itemCount; i++) { + if (albumIds[i] !== anchorAlbum) break; + result.add(i); + } + return result; +} diff --git a/apps/mobile/src/logic/lib/queuedleLogic.ts b/apps/mobile/src/logic/lib/queuedleLogic.ts new file mode 100644 index 0000000..d916772 --- /dev/null +++ b/apps/mobile/src/logic/lib/queuedleLogic.ts @@ -0,0 +1,99 @@ +// Pure Queuedle helpers (ported from the inline logic in desktop QueuedlePanel). +import { computeQueuedleRating, getGameRankTier } from '../hooks/useDailyGame'; +import type { GameRanking } from '../hooks/useDailyGame'; + +export type Side = 'left' | 'right'; + +export interface RankSnapshot { + rating: number; + averagePercent: number; + gamesPlayed: number; + tierKey: string; + tierName: string; + isProvisional: boolean; +} + +/** Today's date (YYYY-MM-DD) in Europe/London, which is how game ids are keyed. */ +export function londonDateToday(now: Date = new Date()): string { + const parts = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/London', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(now); + const get = (t: string, d: string) => parts.find((p) => p.type === t)?.value ?? d; + return `${get('year', '0000')}-${get('month', '01')}-${get('day', '01')}`; +} + +/** The item the bonus (top queuer) round asks about for a question. Old games fall back to the winner. */ +export function bonusTarget(q: GameQuestion): GameItem { + const side = q.bonusItem ?? q.winner; + return side === 'left' ? q.left : q.right; +} + +export function winningItems(questions: GameQuestion[]): GameItem[] { + return questions.map(bonusTarget); +} + +export function scoreMain(questions: GameQuestion[], guesses: Side[]): number { + return guesses.reduce((acc, g, i) => acc + (questions[i] && g === questions[i].winner ? 1 : 0), 0); +} + +export function scoreBonus(questions: GameQuestion[], guesses: string[]): number { + return guesses.reduce((acc, g, i) => acc + (questions[i] && g === bonusTarget(questions[i]).topQueuer ? 1 : 0), 0); +} + +/** Which side a carry-over item sat on in the previous question (for context), or null. */ +export function carryFromSide(questions: GameQuestion[], idx: number): Side | null { + const q = questions[idx]; + if (idx === 0 || !q?.carryover) return null; + const item = q.carryover === 'left' ? q.left : q.right; + const prev = questions[idx - 1]; + if (prev.left.id === item.id && prev.left.category === item.category) return 'left'; + if (prev.right.id === item.id && prev.right.category === item.category) return 'right'; + return null; +} + +export type PipState = 'correct' | 'wrong' | 'current' | 'pending'; + +export function pipStates(questions: GameQuestion[], currentIdx: number, revealed: boolean, guesses: Side[]): PipState[] { + return questions.map((q, i) => { + if (i < currentIdx) return guesses[i] === q.winner ? 'correct' : 'wrong'; + if (i === currentIdx && revealed && guesses[i]) return guesses[i] === q.winner ? 'correct' : 'wrong'; + if (i === currentIdx) return 'current'; + return 'pending'; + }); +} + +export function snapshotFromRanking(r: GameRanking | null | undefined): RankSnapshot | null { + if (!r) return null; + return { + rating: computeQueuedleRating(r.averagePercent), + averagePercent: r.averagePercent, + gamesPlayed: r.gamesPlayed, + tierKey: r.tierKey, + tierName: r.tierName, + isProvisional: r.isProvisional, + }; +} + +/** Projected ranking after today's score is added to the user's prior totals. */ +export function projectRanking(before: GameRanking | null | undefined, todayScore: number, questionCount: number): RankSnapshot { + const totalScore = (before?.totalScore ?? 0) + todayScore; + const possibleScore = (before?.possibleScore ?? 0) + questionCount * 2; + const gamesPlayed = (before?.gamesPlayed ?? 0) + 1; + const averagePercent = possibleScore > 0 ? (totalScore / possibleScore) * 100 : 0; + const tier = getGameRankTier(averagePercent, gamesPlayed); + return { + rating: computeQueuedleRating(averagePercent), + averagePercent, + gamesPlayed, + tierKey: tier.key, + tierName: tier.name, + isProvisional: tier.isProvisional, + }; +} + +/** Bucket scores 0..max for the distribution chart, as percentages of players. */ +export function distribution(scores: number[], maxScore: number): { score: number; pct: number }[] { + const counts = Array.from({ length: maxScore + 1 }, () => 0); + scores.forEach((s) => { + if (s >= 0 && s <= maxScore) counts[s]++; + }); + return counts.map((c, score) => ({ score, pct: scores.length > 0 ? (c / scores.length) * 100 : 0 })); +} diff --git a/apps/mobile/src/logic/lib/recentSearches.ts b/apps/mobile/src/logic/lib/recentSearches.ts new file mode 100644 index 0000000..6c16058 --- /dev/null +++ b/apps/mobile/src/logic/lib/recentSearches.ts @@ -0,0 +1,64 @@ +// Recent search queries as pure logic over an injected KeyValueStore. +import type { KeyValueStore } from '../../office/config'; + +export const RECENT_SEARCHES_KEY = 'recent.searches'; +export const MAX_RECENT = 10; +export const MAX_QUERY_LENGTH = 100; + +function clean(q: unknown): string { + return typeof q === 'string' ? q.trim().replace(/\s+/g, ' ').slice(0, MAX_QUERY_LENGTH) : ''; +} + +/** Normalise: trim, drop blanks/non-strings, dedupe case-insensitively (first wins), cap at MAX_RECENT. */ +export function normaliseRecent(list: unknown): string[] { + if (!Array.isArray(list)) return []; + const seen = new Set(); + const out: string[] = []; + for (const item of list) { + const q = clean(item); + const key = q.toLowerCase(); + if (!q || seen.has(key)) continue; + seen.add(key); + out.push(q); + if (out.length >= MAX_RECENT) break; + } + return out; +} + +export async function loadRecent(store: KeyValueStore): Promise { + try { + const raw = await store.get(RECENT_SEARCHES_KEY); + if (!raw) return []; + return normaliseRecent(JSON.parse(raw)); + } catch { + return []; + } +} + +async function save(store: KeyValueStore, list: string[]): Promise { + try { + if (list.length === 0) await store.remove(RECENT_SEARCHES_KEY); + else await store.set(RECENT_SEARCHES_KEY, JSON.stringify(list)); + } catch { + // Persistence is best-effort; still return the in-memory result. + } + return list; +} + +/** Add a query at the front (moving an existing case-insensitive match). Returns the new list. */ +export async function addRecent(store: KeyValueStore, query: string): Promise { + const q = clean(query); + const current = await loadRecent(store); + if (!q) return current; + return save(store, normaliseRecent([q, ...current])); +} + +export async function removeRecent(store: KeyValueStore, query: string): Promise { + const key = clean(query).toLowerCase(); + const current = await loadRecent(store); + return save(store, current.filter((r) => r.toLowerCase() !== key)); +} + +export async function clearRecent(store: KeyValueStore): Promise { + return save(store, []); +} diff --git a/apps/mobile/src/logic/lib/searchRules.ts b/apps/mobile/src/logic/lib/searchRules.ts new file mode 100644 index 0000000..a1bbeef --- /dev/null +++ b/apps/mobile/src/logic/lib/searchRules.ts @@ -0,0 +1,4 @@ +/** A single letter matches half the catalogue and costs a Sonos request; wait for two. */ +export const MIN_SEARCH_CHARS = 2; + +export const isSearchable = (query: string): boolean => query.trim().length >= MIN_SEARCH_CHARS; diff --git a/apps/mobile/src/logic/lib/socialFormat.ts b/apps/mobile/src/logic/lib/socialFormat.ts new file mode 100644 index 0000000..2ca786b --- /dev/null +++ b/apps/mobile/src/logic/lib/socialFormat.ts @@ -0,0 +1,65 @@ +// Pure helpers for the social screens (lyrics, profile, leaderboard, feedback). +import type { LyricLine } from '../hooks/useLyrics'; + +/** Index of the last lyric line whose timestamp has passed, or -1. Lines must be time-ordered. */ +export function activeLyricIndex(lines: LyricLine[], elapsedMs: number): number { + let idx = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].timeMs <= elapsedMs) idx = i; + else break; + } + return idx; +} + +/** Opacity falloff around the active lyric line (desktop: 1 - dist * 0.19, floor 0.18). */ +export function lyricOpacity(index: number, active: number): number { + if (active < 0) return 1; + return Math.max(0.18, 1 - Math.abs(index - active) * 0.19); +} + +/** Deterministic avatar colour from a name (desktop getAvatarStyle, minus the gradient). */ +export function avatarColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + hash |= 0; + } + return `hsl(${Math.abs(hash) % 360},55%,36%)`; +} + +export function plural(n: number, one: string, many = `${one}s`): string { + return `${n} ${n === 1 ? one : many}`; +} + +/** "since 4/2/2026" / "total" footer line from the stats response. */ +export function statsFooter(totalEvents: number, periodStart: number, userName?: string | null): string { + const base = `${plural(totalEvents, 'queue event')}${userName ? ` by ${userName}` : ''}`; + return periodStart > 0 ? `${base} since ${new Date(periodStart).toLocaleDateString()}` : `${base} total`; +} + +/** Percentage bar width (0-100) relative to the leader. */ +export function barPercent(value: number, max: number): number { + if (!max || max <= 0) return 0; + return Math.max(0, Math.min(100, Math.round((value / max) * 100))); +} + +export const QUEUEDLE_TIER_INFO = [ + { key: 'skip-button-survivor', name: 'Skip Button Survivor', range: '< 40%', emoji: '⏭️' }, + { key: 'background-bopper', name: 'Background Bopper', range: '40% - 54.9%', emoji: '🎧' }, + { key: 'aux-cable-apprentice', name: 'Aux Cable Apprentice', range: '55% - 69.9%', emoji: '🔌' }, + { key: 'algorithm-whisperer', name: 'Algorithm Whisperer', range: '70% - 84.9%', emoji: '🔮' }, + { key: 'playlist-prophet', name: 'Playlist Prophet', range: '85%+', emoji: '🏆' }, +] as const; + +export function tierEmoji(key: string): string { + return QUEUEDLE_TIER_INFO.find((t) => t.key === key)?.emoji ?? '🎵'; +} + +export const FEEDBACK_REPO = 'TrueNorthIT/TrueTunes'; + +/** Prefilled GitHub "new issue" URL (the desktop dialog posts with a PAT, which mobile must not ship). */ +export function feedbackIssueUrl(kind: 'bug' | 'feature', title: string, description: string, who?: string | null): string { + const body = `${description.trim()}\n\n---\nSubmitted from True Tunes Android${who ? ` by ${who}` : ''}`; + const p = new URLSearchParams({ title: title.trim(), body, labels: kind === 'bug' ? 'bug' : 'enhancement' }); + return `https://github.com/${FEEDBACK_REPO}/issues/new?${p.toString()}`; +} diff --git a/apps/mobile/src/logic/lib/sonosApi.ts b/apps/mobile/src/logic/lib/sonosApi.ts new file mode 100644 index 0000000..89337d4 --- /dev/null +++ b/apps/mobile/src/logic/lib/sonosApi.ts @@ -0,0 +1,166 @@ +// renderer/src/lib/sonosApi.ts +// ES module version of the Sonos API client. +// Calls window.sonos.fetch (set by preload contextBridge) under the hood. + +// ─── IPC types ─────────────────────────────────────────────────────────────── + +interface FetchResponse { + data?: unknown; + error?: string; + etag?: string; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function call( + operationId: string, + options: { + pathParams?: Record; + query?: Record; + body?: unknown; + headers?: Record; + } = {} +): Promise { + return window.sonos.fetch({ operationId, ...options }); +} + +function defined(obj: Record): Record { + return Object.fromEntries( + Object.entries(obj).filter((e): e is [string, string] => e[1] !== undefined) + ); +} + +// ─── API surface ────────────────────────────────────────────────────────────── + +function buildApi() { + return { + + auth: { + getDiscovery: () => call('getAuthDiscovery'), + }, + + groups: { + list: () => call('getGroups'), + }, + + playback: { + getState: (groupId?: string) => call('getPlaybackState', { pathParams: defined({ groupId }) }), + play: (groupId?: string) => call('play', { pathParams: defined({ groupId }) }), + pause: (groupId?: string) => call('pause', { pathParams: defined({ groupId }) }), + skipNext: (groupId?: string) => call('skipToNextTrack', { pathParams: defined({ groupId }) }), + skipPrev: (groupId?: string) => call('skipToPreviousTrack', { pathParams: defined({ groupId }) }), + setPlayMode: (body: { shuffle?: boolean; repeat?: boolean; repeatOne?: boolean; crossfade?: boolean }, groupId?: string) => + call('setPlayMode', { pathParams: defined({ groupId }), body }), + }, + + queue: { + list: (params?: { groupId?: string; queueId?: string; serviceId?: string; accountId?: string; count?: number; offset?: number }) => + call('getQueueResources', { + pathParams: defined({ groupId: params?.groupId, queueId: params?.queueId, serviceId: params?.serviceId, accountId: params?.accountId }), + query: defined({ count: params?.count?.toString(), offset: params?.offset?.toString() }), + }), + + add: (body: unknown, params?: { groupId?: string; queueId?: string; serviceId?: string; accountId?: string; position?: number; ifMatch?: string }) => + call('addQueueResource', { + pathParams: defined({ groupId: params?.groupId, queueId: params?.queueId, serviceId: params?.serviceId, accountId: params?.accountId }), + query: { position: String(params?.position ?? -1) }, + headers: params?.ifMatch ? { 'If-Match': params.ifMatch } : undefined, + body, + }), + + remove: (items: number[], params?: { groupId?: string; queueId?: string; serviceId?: string; accountId?: string }) => + call('deleteQueueResources', { + pathParams: defined({ groupId: params?.groupId, queueId: params?.queueId, serviceId: params?.serviceId, accountId: params?.accountId }), + query: { items: items.join(',') }, + }), + + reorder: (items: number[], positions: number[], body: unknown[], params?: { groupId?: string; queueId?: string; serviceId?: string; accountId?: string }) => + call('reorderQueueResources', { + pathParams: defined({ groupId: params?.groupId, queueId: params?.queueId, serviceId: params?.serviceId, accountId: params?.accountId }), + query: { items: items.join(','), positions: positions.join(',') }, + body, + }), + }, + + browse: { + album: (albumId: string, params?: { serviceId?: string; accountId?: string; defaults?: string; muse2?: boolean; count?: number; offset?: number }) => + call('browseAlbum', { + pathParams: { albumId, ...defined({ serviceId: params?.serviceId, accountId: params?.accountId }) }, + query: defined({ defaults: params?.defaults, muse2: params?.muse2 ? 'true' : undefined, count: params?.count?.toString(), offset: params?.offset?.toString() }), + }), + + artist: (artistId: string, params?: { serviceId?: string; accountId?: string; defaults?: string; muse2?: boolean }) => + call('browseArtist', { + pathParams: { artistId, ...defined({ serviceId: params?.serviceId, accountId: params?.accountId }) }, + query: defined({ defaults: params?.defaults, muse2: params?.muse2 ? 'true' : undefined }), + }), + + container: (containerId: string, params?: { serviceId?: string; accountId?: string; defaults?: string; muse2?: boolean }) => + call('browseContainer', { + pathParams: { containerId, ...defined({ serviceId: params?.serviceId, accountId: params?.accountId }) }, + query: defined({ defaults: params?.defaults, muse2: params?.muse2 ? 'true' : undefined }), + }), + + playlist: (playlistId: string, params?: { serviceId?: string; accountId?: string; defaults?: string; muse2?: boolean; count?: number }) => + call('browsePlaylist', { + pathParams: { playlistId, ...defined({ serviceId: params?.serviceId, accountId: params?.accountId }) }, + query: defined({ defaults: params?.defaults, muse2: params?.muse2 ? 'true' : undefined, count: params?.count?.toString() }), + }), + + catalogContainer: (containerId: string, params?: { serviceId?: string; accountId?: string; count?: number }) => + call('getCatalogContainerResources', { + pathParams: { containerId, ...defined({ serviceId: params?.serviceId, accountId: params?.accountId }) }, + query: defined({ count: params?.count?.toString() }), + }), + }, + + content: { + favorites: (params?: { serviceId?: string; accountId?: string; count?: number; resources?: string }) => + call('getFavoriteResources', { + pathParams: defined({ serviceId: params?.serviceId, accountId: params?.accountId }), + query: defined({ count: params?.count?.toString(), resources: params?.resources }), + }), + + history: (params?: { serviceId?: string; accountId?: string; count?: number }) => + call('getHistory', { + pathParams: defined({ serviceId: params?.serviceId, accountId: params?.accountId }), + query: defined({ count: params?.count?.toString() }), + }), + }, + + search: { + query: (q: string, services?: string[]) => + call('searchHousehold', { + query: defined({ query: q, services: services?.join(',') }), + }), + + serviceQuery: (q: string, params?: { serviceId?: string; accountId?: string; count?: number }) => + call('searchService', { + pathParams: defined({ serviceId: params?.serviceId, accountId: params?.accountId }), + query: defined({ query: q, count: params?.count?.toString() }), + }), + }, + + integrations: { + list: () => call('getIntegrations'), + registrations: () => call('getIntegrationRegistrations'), + }, + + nowPlaying: { + track: (trackId: string, params?: { serviceId?: string; accountId?: string }) => + call('getTrackNowPlaying', { + pathParams: { trackId, ...defined({ serviceId: params?.serviceId, accountId: params?.accountId }) }, + }), + }, + + platform: { + mfe: () => call('getMfe'), + featureFlag: (key: string) => call('getOptimizelyConfig', { pathParams: { key } }), + metrics: (events: unknown[]) => call('postMetrics', { body: events }), + }, + + }; +} + +export type SonosApiClient = ReturnType; +export const api = buildApi(); diff --git a/apps/mobile/src/logic/lib/ytmSections.ts b/apps/mobile/src/logic/lib/ytmSections.ts new file mode 100644 index 0000000..26ada2c --- /dev/null +++ b/apps/mobile/src/logic/lib/ytmSections.ts @@ -0,0 +1,70 @@ +// Ported from desktop HomePanel.fetchYtmSections: the YouTube Music home shelves via muse2 browse. +import type { SonosItem, SonosItemId } from '../types/sonos'; +import { extractItems } from './itemHelpers'; +import { api } from './sonosApi'; + +export interface YtmSections { + forYou: SonosItem[]; + newReleases: SonosItem[]; + charts: SonosItem[]; +} + +const EMPTY: YtmSections = { forYou: [], newReleases: [], charts: [] }; + +async function browseContainer(item: SonosItem | undefined): Promise { + if (!item) return []; + const rid = item.resource?.id as SonosItemId | undefined; + if (!rid?.objectId) return []; + const r = await api.browse.container(rid.objectId, { + serviceId: rid.serviceId, + accountId: rid.accountId, + defaults: item.resource?.defaults as string | undefined, + muse2: true, + }); + if (r.error) { + if (typeof __DEV__ !== 'undefined' && __DEV__) console.warn('[ytm] container browse failed:', rid.objectId, r.error); + return []; + } + return extractItems(r.data); +} + +export async function fetchYtmSections(): Promise { + const rootR = await api.browse.container('root', { muse2: true }); + if (rootR.error) { + if (typeof __DEV__ !== 'undefined' && __DEV__) console.warn('[ytm] root browse failed:', rootR.error); + return { ...EMPTY }; + } + + const d = rootR.data as Record; + const stack = ((d['sections'] as Record | undefined)?.['items'] as unknown[] | undefined)?.[0] as + | Record + | undefined; + const rootItems = (stack?.['items'] ?? []) as SonosItem[]; + const find = (title: string) => rootItems.find((i) => (i.title ?? i.name) === title); + + // Home is the container that carries the shelves. + const homeItem = find('Home'); + const homeItems = await browseContainer(homeItem); + const findShelf = (...titles: string[]) => { + for (const t of titles) { + const hit = homeItems.find((i) => (i.title ?? i.name) === t); + if (hit) return hit; + } + return undefined; + }; + + // Desktop reads 'New releases' / 'Charts' from the root. Some accounts (this one included) only + // expose Home / Library / My Supermix there, with those shelves inside Home instead, so fall back. + const [newItems, chartItems] = await Promise.all([ + browseContainer(find('New releases') ?? findShelf('New releases', 'Fresh new music')), + browseContainer(find('Charts') ?? findShelf('Top songs', 'Trending songs for you')), + ]); + + const supermix = find('My Supermix'); + return { + // Desktop swapped in the app icon for the Supermix tile; here it keeps whatever art Sonos gave it. + forYou: supermix ? [supermix, ...homeItems] : homeItems, + newReleases: newItems, + charts: chartItems, + }; +} diff --git a/apps/mobile/src/logic/providers/AudioProvider.ts b/apps/mobile/src/logic/providers/AudioProvider.ts new file mode 100644 index 0000000..9d72a52 --- /dev/null +++ b/apps/mobile/src/logic/providers/AudioProvider.ts @@ -0,0 +1,37 @@ +import type { NormalizedGroup, NormalizedPlaybackState, NormalizedQueueItem, ProviderId } from '../types/provider'; + +type Unsubscribe = () => void; + +export interface AudioProvider { + readonly id: ProviderId; + + // Transport + play(): Promise; + pause(): Promise; + skipNext(): Promise; + skipPrev(): Promise; + seek(positionMs: number): Promise; + setPlayModes(modes: { shuffle?: boolean; repeat?: 'none' | 'one' | 'all' }): Promise; + loadContent(payload: Record): Promise; + skipToTrack(trackNumber: number): Promise; + refreshPlayback(): Promise; + + // Queue + getQueue(params?: { count?: number; offset?: number }): Promise<{ items: NormalizedQueueItem[]; etag?: string; error?: string }>; + removeFromQueue(indices: number[]): Promise; + reorderQueue(fromIndices: number[], toIndex: number, queueLength: number): Promise; + clearQueue(): Promise; + + // Output / groups + getActiveGroup(): Promise; + setGroup(groupId: string): Promise; + setVolume(volume: number): Promise; + + // Subscriptions + // onState receives groupId (undefined if provider has no group concept) plus the normalized state + subscribePlayback( + onState: (groupId: string | undefined, state: NormalizedPlaybackState) => void, + onVolume: (volume: number) => void, + ): Unsubscribe; + subscribeGroups(onGroups: (groups: NormalizedGroup[]) => void): Unsubscribe; +} diff --git a/apps/mobile/src/logic/providers/index.ts b/apps/mobile/src/logic/providers/index.ts new file mode 100644 index 0000000..ffed1a9 --- /dev/null +++ b/apps/mobile/src/logic/providers/index.ts @@ -0,0 +1,10 @@ +import { SonosProvider } from './sonos/SonosProvider'; +import type { AudioProvider } from './AudioProvider'; + +const _sonos: AudioProvider = new SonosProvider(); + +export function getActiveProvider(): AudioProvider { + return _sonos; +} + +// Future: export function setActiveProvider(id: ProviderId) { ... } diff --git a/apps/mobile/src/logic/providers/sonos/SonosProvider.ts b/apps/mobile/src/logic/providers/sonos/SonosProvider.ts new file mode 100644 index 0000000..29d6966 --- /dev/null +++ b/apps/mobile/src/logic/providers/sonos/SonosProvider.ts @@ -0,0 +1,180 @@ +import { api } from '../../lib/sonosApi'; +import { extractItems } from '../../lib/itemHelpers'; +import type { AudioProvider } from '../AudioProvider'; +import type { NormalizedGroup, NormalizedPlaybackState, NormalizedQueueItem, NormalizedTrack } from '../../types/provider'; +import type { PlaybackPayload, SonosItem, SonosItemId, SonosTrack } from '../../types/sonos'; + +function resolveArtistName(raw: SonosTrack['artist']): string { + if (!raw) return ''; + if (typeof raw === 'object') return raw.name ?? ''; + return raw; +} + +function resolveImageUrl(track: SonosTrack): string | null { + const urls = [ + ...(track.images?.map((i) => i.url) ?? []), + track.imageUrl, + ].filter((u): u is string => !!u); + return urls.find((u) => u.startsWith('https://')) ?? null; +} + +function normalizeTrack(track: SonosTrack): NormalizedTrack { + const id = track.id as SonosItemId | undefined; + const albumObj = track.album as { name?: string; id?: { objectId?: string } } | undefined; + return { + id: id?.objectId ?? '', + title: track.name ?? track.title ?? '', + artist: resolveArtistName(track.artist ?? track.primaryArtist), + albumName: albumObj?.name ?? null, + albumId: albumObj?.id?.objectId ?? null, + imageUrl: resolveImageUrl(track), + durationMs: track.durationMillis ?? 0, + isExplicit: !!(track.explicit ?? (track as Record)['isExplicit']), + serviceId: id?.serviceId ?? null, + accountId: id?.accountId ?? null, + }; +} + +export function normalizePlaybackPayload(payload: PlaybackPayload): NormalizedPlaybackState { + const track = payload?.metadata?.currentItem?.track; + const stateVal = payload?.playback?.playbackState ?? ''; + const repeatOne = payload?.playback?.playModes?.repeatOne ?? false; + const repeatAll = payload?.playback?.playModes?.repeat ?? false; + const status = stateVal.includes('PLAYING') ? 'playing' as const + : stateVal.includes('PAUSED') ? 'paused' as const + : 'idle' as const; + + return { + status, + isPlaying: status === 'playing', + positionMs: payload?.playback?.positionMillis ?? 0, + durationMs: track?.durationMillis ?? 0, + currentTrack: track ? normalizeTrack(track) : null, + shuffle: payload?.playback?.playModes?.shuffle ?? false, + repeatMode: repeatOne ? 'one' : repeatAll ? 'all' : 'none', + queueId: payload?.playback?.queueId ?? null, + queueVersion: payload?.playback?.queueVersion ?? null, + queueItemId: payload?.playback?.itemId ?? null, + }; +} + +function normalizeSonosItemToQueueItem(item: SonosItem, index: number): NormalizedQueueItem { + const track = item.track as SonosTrack | undefined; + const id = (track?.id ?? item.id) as SonosItemId | undefined; + const albumObj = track?.album as { name?: string; id?: { objectId?: string } } | undefined; + + const rawArt = track + ? resolveImageUrl(track) + : ((item.images as Record | undefined)?.['tile1x1'] ?? item.imageUrl ?? null); + + const rawArtist = track?.artist ?? track?.primaryArtist ?? item.artist ?? item.primaryArtist; + + return { + index, + track: { + id: id?.objectId ?? '', + title: track?.name ?? track?.title ?? item.name ?? item.title ?? '', + artist: resolveArtistName(rawArtist), + albumName: albumObj?.name ?? null, + albumId: albumObj?.id?.objectId ?? null, + imageUrl: rawArt ?? null, + durationMs: track?.durationMillis ?? 0, + isExplicit: !!( + track?.explicit ?? + (track as Record | undefined)?.['isExplicit'] + ), + serviceId: id?.serviceId ?? null, + accountId: id?.accountId ?? null, + }, + }; +} + +function throwOnIpcError(r: unknown): void { + if (r && typeof r === 'object' && 'error' in r && (r as { error: unknown }).error) { + throw new Error(String((r as { error: unknown }).error)); + } +} + +export class SonosProvider implements AudioProvider { + readonly id = 'sonos' as const; + + play() { return window.sonos.play() as Promise; } + pause() { return window.sonos.pause() as Promise; } + skipNext() { return window.sonos.skipNext() as Promise; } + skipPrev() { return window.sonos.skipPrev() as Promise; } + seek(ms: number) { return window.sonos.seek(ms) as Promise; } + loadContent(p: Record) { return window.sonos.loadContent(p); } + skipToTrack(n: number) { return window.sonos.skipToTrack(n) as Promise; } + refreshPlayback() { return window.sonos.refreshPlayback() as Promise; } + + setPlayModes(modes: { shuffle?: boolean; repeat?: 'none' | 'one' | 'all' }) { + const payload: { shuffle?: boolean; repeat?: boolean; repeatOne?: boolean } = {}; + if (modes.shuffle !== undefined) payload.shuffle = modes.shuffle; + if (modes.repeat !== undefined) { + payload.repeat = modes.repeat === 'all'; + payload.repeatOne = modes.repeat === 'one'; + } + return window.sonos.setPlayModes(payload) as Promise; + } + + async getQueue(params?: { count?: number; offset?: number }): Promise<{ items: NormalizedQueueItem[]; etag?: string; error?: string }> { + const r = await api.queue.list({ count: params?.count, offset: params?.offset }); + if (r.error) return { items: [], error: r.error }; + const raw = extractItems(r.data) as SonosItem[]; + const offset = params?.offset ?? 0; + return { + items: raw.map((item, i) => normalizeSonosItemToQueueItem(item, offset + i)), + etag: r.etag, + }; + } + + removeFromQueue(indices: number[]) { + return window.sonos.removeFromQueue(indices).then(throwOnIpcError); + } + clearQueue() { + return window.sonos.clearQueue().then(throwOnIpcError); + } + reorderQueue(from: number[], to: number, len: number) { + return window.sonos.reorderQueue(from, to, len).then(throwOnIpcError); + } + + getActiveGroup() { return window.sonos.getActiveGroup(); } + setGroup(groupId: string) { return window.sonos.setGroup(groupId).then(() => undefined); } + setVolume(volume: number) { return window.sonos.setGroupVolume(volume) as Promise; } + + subscribePlayback( + onState: (groupId: string | undefined, state: NormalizedPlaybackState) => void, + onVolume: (volume: number) => void, + ) { + return window.sonos.onWsMessage((header, payload) => { + const h = header as Record; + + if (h?.['namespace'] === 'groupVolume') { + const vol = (payload as { volume?: number })?.volume; + if (vol !== undefined) onVolume(vol); + return; + } + + if (h?.['namespace'] !== 'playbackExtended') return; + + const p = payload as PlaybackPayload; + const groupId = h?.['groupId'] as string | undefined; + + // Sonos signals no active groups — zero out volume + if ((p?.playback?.playbackState ?? '') === 'NO_GROUPS') { + onVolume(0); + } + + onState(groupId, normalizePlaybackPayload(p)); + }); + } + + subscribeGroups(onGroups: (groups: NormalizedGroup[]) => void) { + return window.sonos.onWsGroups((raw) => { + const groups = (raw as Array<{ id: string; name: string; coordinatorId?: string }>).map( + (g): NormalizedGroup => ({ id: g.id, name: g.name, providerId: 'sonos', coordinatorId: g.coordinatorId }), + ); + onGroups(groups); + }); + } +} diff --git a/apps/mobile/src/logic/test/setup.ts b/apps/mobile/src/logic/test/setup.ts new file mode 100644 index 0000000..5d942df --- /dev/null +++ b/apps/mobile/src/logic/test/setup.ts @@ -0,0 +1,99 @@ +import '@testing-library/jest-dom/vitest'; +import { vi } from 'vitest'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +const noop = () => () => {}; +const pending = () => new Promise(() => {}); + +Object.defineProperty(window, 'sonos', { + writable: true, + value: { + fetchStats: vi.fn(pending), + fetchDailyGame: vi.fn(pending), + submitGameScore: vi.fn(pending), + fetchGameLeaderboard: vi.fn(pending), + fetchGameDates: vi.fn(pending), + fetchMyScore: vi.fn(pending), + fetchGameStats: vi.fn(pending), + fetch: vi.fn(pending), + fetchImage: vi.fn(pending), + onAuthReady: vi.fn(noop), + onAuthExpired: vi.fn(noop), + onWsMessage: vi.fn(noop), + onWsReady: vi.fn(noop), + onWsGroups: vi.fn(noop), + onAttributionMap: vi.fn(noop), + onAttributionEvent: vi.fn(noop), + getActiveGroup: vi.fn(() => Promise.resolve(null)), + setGroup: vi.fn(pending), + setGroupVolume: vi.fn(pending), + setQueueId: vi.fn(() => Promise.resolve()), + loadContent: vi.fn(pending), + setPlayModes: vi.fn(pending), + skipToTrack: vi.fn(pending), + seek: vi.fn(pending), + reorderQueue: vi.fn(pending), + removeFromQueue: vi.fn(pending), + clearQueue: vi.fn(pending), + openWsMonitor: vi.fn(() => Promise.resolve()), + openHttpMonitor: vi.fn(() => Promise.resolve()), + openDevTools: vi.fn(() => Promise.resolve()), + openMiniPlayer: vi.fn(() => Promise.resolve()), + closeMiniPlayer: vi.fn(() => Promise.resolve()), + getDisplayName: vi.fn(pending), + setDisplayName: vi.fn(pending), + getQueueDockedWidth: vi.fn(() => Promise.resolve(380)), + setQueueDockedWidth: vi.fn(() => Promise.resolve()), + publishQueued: vi.fn(pending), + refreshAttribution: vi.fn(pending), + refreshPlayback: vi.fn(pending), + resync: vi.fn(pending), + play: vi.fn(pending), + pause: vi.fn(pending), + skipNext: vi.fn(pending), + skipPrev: vi.fn(pending), + getVersion: vi.fn(pending), + isNewVersion: vi.fn(() => Promise.resolve(false)), + openExternal: vi.fn(() => Promise.resolve()), + fetchRecentlyPlayed: vi.fn(() => Promise.resolve(null)), + fetchDjSet: vi.fn(() => Promise.resolve({ tracks: [], artists: [], listeners: [], artistsConsidered: 0, inferredListeners: true })), + fetchOfficePresence: vi.fn(() => Promise.resolve({ inOffice: [], basis: 'none', observed: 0, detail: [] })), + djAutoplay: vi.fn(() => Promise.resolve({ enabled: false, leaseHeld: false, enqueue: null, upcoming: [], fillerUri: null })), + geniusDescription: vi.fn(() => Promise.resolve(null)), + geniusArtist: vi.fn(() => Promise.resolve(null)), + geniusAlbumYear: vi.fn(() => Promise.resolve(null)), + trackEvent: vi.fn(() => Promise.resolve()), + minimizeWindow: vi.fn(() => Promise.resolve()), + maximizeWindow: vi.fn(() => Promise.resolve()), + closeWindow: vi.fn(() => Promise.resolve()), + isWindowMaximized: vi.fn(() => Promise.resolve(false)), + onWindowMaximized: vi.fn(() => () => {}), + onUpdateDownloaded: vi.fn(noop), + installUpdate: vi.fn(pending), + fetchPlaylists: vi.fn(() => Promise.resolve([])), + fetchPlaylist: vi.fn(pending), + createPlaylist: vi.fn(pending), + addTrackToPlaylist: vi.fn(pending), + removeTrackFromPlaylist: vi.fn(pending), + reorderPlaylistTracks: vi.fn(pending), + ensureFavourites: vi.fn(pending), + updatePlaylist: vi.fn(pending), + deletePlaylist: vi.fn(pending), + joinPlaylist: vi.fn(pending), + uploadPlaylistImage: vi.fn(pending), + fetchUsers: vi.fn(() => Promise.resolve([])), + fetchUserProfile: vi.fn(() => Promise.resolve(null)), + uploadProfileImage: vi.fn(pending), + getEntraUser: vi.fn(() => Promise.resolve(null)), + entraSignOut: vi.fn(() => Promise.resolve()), + entraReLogin: vi.fn(() => Promise.resolve()), + onEntraReady: vi.fn(noop), + renameUser: vi.fn(() => Promise.resolve({ ok: true })), + }, +}); diff --git a/apps/mobile/src/logic/types/AlbumResponse.d.ts b/apps/mobile/src/logic/types/AlbumResponse.d.ts new file mode 100644 index 0000000..663f784 --- /dev/null +++ b/apps/mobile/src/logic/types/AlbumResponse.d.ts @@ -0,0 +1,82 @@ +export interface AlbumResponse { + type: string; + id: string; + resource: Resource; + title: string; + subtitle: string; + images: Images; + isExplicit: boolean; + providersInfo: any[]; + actions: string[]; + customActions: any[]; + sections: Sections; + tracks: Tracks; + providerInfo: ProviderInfo; +} + +interface Resource { + type: string; + id: Id; + defaults: string; +} + +interface Id { + objectId: string; + serviceId: string; + accountId: string; +} + +interface Images { + tile1x1: string; +} + +interface Sections { + items: any[]; +} + +interface Tracks { + items: Item[]; + total: number; + fastScrollUri: string; +} + +interface Item { + id: string; + title: string; + subtitle: string; + images: Images2; + actions: string[]; + type: string; + resource: Resource2; + artists: Artist[]; + isExplicit: boolean; + ordinal: number; + duration: string; +} + +interface Images2 { + tile1x1: string; +} + +interface Resource2 { + id: Id2; + type: string; + defaults: string; +} + +interface Id2 { + objectId: string; + serviceId: string; + accountId: string; +} + +interface Artist { + id: string; + name: string; +} + +interface ProviderInfo { + id: string; + slug: string; + name: string; +} diff --git a/apps/mobile/src/logic/types/ArtistResponse.d.ts b/apps/mobile/src/logic/types/ArtistResponse.d.ts new file mode 100644 index 0000000..9d72537 --- /dev/null +++ b/apps/mobile/src/logic/types/ArtistResponse.d.ts @@ -0,0 +1,74 @@ +export interface ArtistResponse { + type: string; + id: string; + resource: Resource; + title: string; + images: Images; + actions: string[]; + customActions: any[]; + sections: Sections; + providerInfo: ProviderInfo; +} + +interface Resource { + type: string; + id: Id; + defaults: string; +} + +interface Id { + objectId: string; + serviceId: string; + accountId: string; +} + +interface Images { + tile1x1: string; +} + +interface Sections { + items: Item[]; +} + +interface Item { + id: string; + type: string; + href: string; + items: Item2[]; + total: number; + fastScrollUri: string; +} + +interface Item2 { + id: string; + title: string; + images: Images2; + actions: string[]; + type: string; + resource: Resource2; + isExplicit: boolean; + subtitle?: string; + href?: string; +} + +interface Images2 { + tile1x1: string; +} + +interface Resource2 { + id: Id2; + type: string; + defaults: string; +} + +interface Id2 { + objectId: string; + serviceId: string; + accountId: string; +} + +interface ProviderInfo { + id: string; + slug: string; + name: string; +} diff --git a/apps/mobile/src/logic/types/PlaylistResponse.d.ts b/apps/mobile/src/logic/types/PlaylistResponse.d.ts new file mode 100644 index 0000000..4207145 --- /dev/null +++ b/apps/mobile/src/logic/types/PlaylistResponse.d.ts @@ -0,0 +1,127 @@ +export interface PlaylistResponse { + type: string; + id: string; + resource: Resource; + title: string; + subtitle: string; + images: Images; + owner: Owner; + isEditable: boolean; + isExplicit: boolean; + providersInfo: any[]; + actions: string[]; + customActions: any[]; + sections: Sections; + tracks: Tracks; + resources: Resources; + providerInfo: ProviderInfo; +} + +interface Resource { + type: string; + id: Id; + defaults: string; +} + +interface Id { + objectId: string; + serviceId: string; + accountId: string; +} + +interface Images { + tile1x1: string; +} + +interface Owner { + id: string; + name: string; +} + +interface Sections { + items: any[]; +} + +interface Tracks { + nextUri: string; + items: Item[]; +} + +interface Item { + id: string; + title: string; + subtitle: string; + images: Images2; + actions: string[]; + type: string; + resource: Resource2; + artists: Artist[]; + isExplicit: boolean; + duration: string; +} + +interface Images2 { + tile1x1: string; +} + +interface Resource2 { + id: Id2; + type: string; + defaults: string; +} + +interface Id2 { + objectId: string; + serviceId: string; + accountId: string; +} + +interface Artist { + id: string; + name: string; +} + +interface Resources { + nextUri: string; + items: Item2[]; +} + +interface Item2 { + id: string; + title: string; + subtitle: string; + images: Images3; + actions: string[]; + type: string; + resource: Resource3; + artists: Artist2[]; + isExplicit: boolean; + duration: string; +} + +interface Images3 { + tile1x1: string; +} + +interface Resource3 { + id: Id3; + type: string; + defaults: string; +} + +interface Id3 { + objectId: string; + serviceId: string; + accountId: string; +} + +interface Artist2 { + id: string; + name: string; +} + +interface ProviderInfo { + id: string; + slug: string; + name: string; +} diff --git a/apps/mobile/src/logic/types/ServiceSearch.d.ts b/apps/mobile/src/logic/types/ServiceSearch.d.ts new file mode 100644 index 0000000..d257cc8 --- /dev/null +++ b/apps/mobile/src/logic/types/ServiceSearch.d.ts @@ -0,0 +1,314 @@ +export interface ServiceSearch { + info: Info; + errors: any[]; + ALBUMS: Albums; + ARTISTS: Artists; + PLAYLISTS: Playlists; + PODCASTS: Podcasts; + TRACKS: Tracks; + resourceOrder: string[]; + externalLinks: any[]; +} + +interface Info { + count: number; +} + +interface Albums { + info: Info2; + resources: Resource[]; +} + +interface Info2 { + count: number; + offset: number; + pageSize: number; +} + +interface Resource { + id: Id; + name: string; + type: string; + artists: Artist[]; + playable: boolean; + images: Image[]; + explicit: boolean; + summary: Summary; + enumerable: boolean; + defaults: string; +} + +interface Id { + objectId: string; + serviceId: string; + accountId: string; + serviceName: string; + serviceNameId: string; +} + +interface Artist { + name: string; + id: Id2; + type: string; + playable: boolean; + images: any[]; +} + +interface Id2 { + objectId: string; + serviceId: string; + accountId: string; + serviceName: string; + serviceNameId: string; +} + +interface Image { + url: string; +} + +interface Summary { + content: string; +} + +interface Artists { + info: Info3; + resources: Resource2[]; +} + +interface Info3 { + count: number; + offset: number; + pageSize: number; +} + +interface Resource2 { + id: Id3; + name: string; + type: string; + playable: boolean; + images: Image2[]; + bio: Bio; + defaults: string; +} + +interface Id3 { + objectId: string; + serviceId: string; + accountId: string; + serviceName: string; + serviceNameId: string; +} + +interface Image2 { + url: string; +} + +interface Bio { + content: string; +} + +interface Playlists { + info: Info4; + resources: Resource3[]; +} + +interface Info4 { + count: number; + offset: number; + pageSize: number; +} + +interface Resource3 { + id: Id4; + name: string; + type: string; + images: Image3[]; + playable: boolean; + explicit: boolean; + enumerable: boolean; + readOnly: boolean; + renameable: boolean; + userContent: boolean; + summary: Summary2; + owner: Owner; + defaults: string; +} + +interface Id4 { + objectId: string; + serviceId: string; + accountId: string; + serviceName: string; + serviceNameId: string; +} + +interface Image3 { + url: string; +} + +interface Summary2 { + content: string; +} + +interface Owner { + id: Id5; + name: string; + images: any[]; + type: string; +} + +interface Id5 { + objectId: string; + serviceId: string; + accountId: string; + type: string; +} + +interface Podcasts { + info: Info5; + resources: Resource4[]; +} + +interface Info5 { + count: number; + offset: number; + pageSize: number; +} + +interface Resource4 { + id: Id6; + name: string; + type: string; + hosts: any[]; + images: Image4[]; + playable: boolean; + explicit: boolean; + summary: Summary3; + enumerable: boolean; + producers: Producer[]; + defaults: string; +} + +interface Id6 { + objectId: string; + serviceId: string; + accountId: string; + serviceName: string; + serviceNameId: string; +} + +interface Image4 { + url: string; +} + +interface Summary3 { + content: string; +} + +interface Producer { + name: string; + id: Id7; + type: string; + playable: boolean; +} + +interface Id7 { + objectId: string; + serviceId: string; + accountId: string; + serviceName: string; + serviceNameId: string; +} + +interface Tracks { + info: Info6; + resources: Resource5[]; +} + +interface Info6 { + count: number; + offset: number; + pageSize: number; +} + +interface Resource5 { + id: Id8; + name: string; + type: string; + artists: Artist2[]; + explicit: boolean; + playable: boolean; + summary: Summary4; + images: Image5[]; + ordinal: number; + durationMs: number; + defaults: string; + container?: Container; +} + +interface Id8 { + objectId: string; + serviceId: string; + accountId: string; + serviceName: string; + serviceNameId: string; +} + +interface Artist2 { + name: string; + id: Id9; + type: string; + playable: boolean; +} + +interface Id9 { + objectId: string; + serviceId: string; + accountId: string; + serviceName: string; + serviceNameId: string; +} + +interface Summary4 { + content: string; +} + +interface Image5 { + url: string; +} + +interface Container { + id: Id10; + name: string; + type: string; + artists: Artist3[]; + images: Image6[]; + playable: boolean; +} + +interface Id10 { + objectId: string; + serviceId: string; + accountId: string; + serviceName: string; + serviceNameId: string; +} + +interface Artist3 { + name: string; + id: Id11; + type: string; + playable: boolean; +} + +interface Id11 { + objectId: string; + serviceId: string; + accountId: string; + serviceName: string; + serviceNameId: string; +} + +interface Image6 { + url: string; +} diff --git a/apps/mobile/src/logic/types/provider.ts b/apps/mobile/src/logic/types/provider.ts new file mode 100644 index 0000000..ef97e68 --- /dev/null +++ b/apps/mobile/src/logic/types/provider.ts @@ -0,0 +1,40 @@ +export type ProviderId = 'sonos'; // | 'spotify' — extend per new provider + +export interface NormalizedTrack { + id: string; // provider-scoped unique ID (objectId for Sonos) + title: string; + artist: string; + albumName: string | null; + albumId: string | null; + imageUrl: string | null; + durationMs: number; + isExplicit: boolean; + // Kept for attribution / loadContent until those are also abstracted + serviceId: string | null; + accountId: string | null; +} + +export interface NormalizedQueueItem { + index: number; + track: NormalizedTrack; +} + +export interface NormalizedPlaybackState { + status: 'playing' | 'paused' | 'idle'; + isPlaying: boolean; // convenience alias: status === 'playing' + positionMs: number; + durationMs: number; + currentTrack: NormalizedTrack | null; + shuffle: boolean; + repeatMode: 'none' | 'one' | 'all'; + queueId: string | null; + queueVersion: string | null; + queueItemId: string | null; +} + +export interface NormalizedGroup { + id: string; + name: string; + providerId: ProviderId; + coordinatorId?: string; // Sonos-specific; persists across sessions as the stable device ID +} diff --git a/apps/mobile/src/logic/types/sonos.ts b/apps/mobile/src/logic/types/sonos.ts new file mode 100644 index 0000000..83bdee8 --- /dev/null +++ b/apps/mobile/src/logic/types/sonos.ts @@ -0,0 +1,112 @@ +// Shared TypeScript interfaces for Sonos API response shapes + +export interface GroupInfo { + id: string; // full groupId with session suffix + coordinatorId: string; // bare RINCON ID + name: string; + playerIds: string[]; +} + +export interface SonosItemId { + objectId?: string; + serviceId?: string; + accountId?: string; + serviceName?: string; + serviceNameId?: string; +} + +export interface SonosArtist { + name: string; + id?: SonosItemId; + type?: string; + images?: Array<{ url: string }>; +} + +export interface SonosAlbum { + name: string; + id?: SonosItemId; +} + +export interface SonosTrack { + name?: string; + title?: string; + type?: string; + imageUrl?: string; + images?: Array<{ url: string }>; + album?: SonosAlbum; + artist?: SonosArtist | string; + primaryArtist?: SonosArtist | string; + id?: SonosItemId; + durationMillis?: number; + explicit?: boolean; +} + +// Generic item shape covering history, search results, browse results +export interface SonosItem { + name?: string; + title?: string; + type?: string; + imageUrl?: string; + images?: Array<{ url: string }>; + artist?: SonosArtist | string; + primaryArtist?: SonosArtist | string; + artists?: SonosArtist[]; + album?: SonosAlbum | string; + description?: string; + track?: SonosTrack; + resource?: { + name?: string; + artist?: SonosArtist | string; + type?: string; + id?: SonosItemId; + defaults?: string; + }; + id?: SonosItemId | string; + serviceId?: string; + accountId?: string; + objectId?: string; + summary?: { content?: string }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +// Queue item as returned by getQueueResources +export interface QueueItem extends SonosItem { + track?: SonosTrack; +} + +// Art patch applied asynchronously after nowPlaying API calls +export interface ArtPatch { + artUrl?: string; + subtitle?: string; +} + +// Shape of the extendedPlaybackStatus WS payload +export interface PlaybackPayload { + playback?: { + playbackState?: string; + positionMillis?: number; + itemId?: string; + queueId?: string; + queueVersion?: string; + playModes?: { + shuffle?: boolean; + repeat?: boolean; + repeatOne?: boolean; + crossfade?: boolean; + }; + }; + metadata?: { + currentItem?: { + track?: SonosTrack; + }; + nextItem?: { + track?: SonosTrack; + }; + container?: { + name?: string; + }; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} diff --git a/apps/mobile/src/logic/useAddToQueueImpl.ts b/apps/mobile/src/logic/useAddToQueueImpl.ts new file mode 100644 index 0000000..a48dae0 --- /dev/null +++ b/apps/mobile/src/logic/useAddToQueueImpl.ts @@ -0,0 +1,294 @@ +// Port of desktop MainApp handleAddToQueue + attribution + autoplay wiring (renderer/src/App.tsx). + +import { useCallback, useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRemoteContext } from './RemoteContext'; +import { useToast } from './context/ToastContext'; +import type { AddToQueue } from './AddToQueueContext'; +import { useAutoplay } from './hooks/useAutoplay'; +import { trackQueryOptions } from './hooks/useTrackDetails'; +import { albumQueryOptions, type AlbumTrack } from './hooks/useAlbumBrowse'; +import { playlistQueryOptions } from './hooks/usePlaylistBrowse'; +import { api } from './lib/sonosApi'; +import { + normalizeForQueue, + isTrack, + isProgram, + isAlbum, + isPlaylist, + resolveAlbumParams, + getItemArt, + getName, + sonosItemToNormalizedQueueItem, +} from './lib/itemHelpers'; +import type { SonosItem, SonosItemId } from './types/sonos'; + +export function useAddToQueueImpl(): { addToQueue: AddToQueue; autoplay: ReturnType } { + const queryClient = useQueryClient(); + const { showToast: toast } = useToast(); + const showToast = useCallback((msg: string) => toast(msg, 'error'), [toast]); + const { activeGroupId, playback, queue, queueIdRef, queueVersionRef } = useRemoteContext(); + const { items: queueItems, setItems: setQueueItems, reload: reloadQueue, reloadRaw: reloadQueueRaw } = queue; + + // Read inside addToQueue without making it depend on queue contents. + const queueItemsRef = useRef(queueItems); + const fillerUriRef = useRef(null); + + const publishTrackAttribution = useCallback( + (trackId: string, serviceId: string, accountId: string, fallback: { trackName: string; artist?: string; imageUrl?: string }) => { + // Cached metadata if available; otherwise publish immediately with the fallback. + const cached = queryClient.getQueryData(trackQueryOptions(trackId, serviceId, accountId).queryKey) as + | { trackName?: string; artist?: string; artistId?: string; albumName?: string; albumId?: string; artUrl?: string } + | undefined; + window.sonos + .publishQueued({ + eventType: 'track', + uri: trackId, + trackName: cached?.trackName ?? fallback.trackName, + artist: cached?.artist ?? fallback.artist ?? '', + serviceId, + accountId, + artistId: cached?.artistId, + album: cached?.albumName, + albumId: cached?.albumId, + imageUrl: cached?.artUrl ?? fallback.imageUrl, + }) + .catch(() => {}); + }, + [queryClient], + ); + + /** One 'track' event per track from already-fetched album/playlist data (O(1) Sonos lookups). */ + const fanOutTrackAttribution = useCallback( + ( + tracks: AlbumTrack[], + context: { + album?: string | null; + albumId?: string | null; + artist?: string; + artistId?: string; + artUrl?: string | null; + serviceId?: string; + accountId?: string; + }, + ) => { + for (const t of tracks) { + const tid = t.id; + if (!tid?.objectId) continue; + window.sonos.publishQueued({ + eventType: 'track', + uri: tid.objectId, + trackName: t.title, + artist: t.artists[0] ?? context.artist ?? '', + serviceId: tid.serviceId ?? context.serviceId, + accountId: tid.accountId?.replace(/^sn_/, '') ?? context.accountId, + artistId: t.artistObjects?.[0]?.objectId ?? context.artistId, + album: t.albumName ?? context.album ?? undefined, + albumId: t.albumId ?? context.albumId ?? undefined, + imageUrl: t.artUrl ?? context.artUrl ?? undefined, + }); + } + }, + [], + ); + + const addToQueue = useCallback( + async (item, position = -1, opts) => { + // A user pick goes in front of the autoplay filler parked at the tail. + if (position === -1 && !opts?.djFiller && fillerUriRef.current) { + const fillerIndex = queueItemsRef.current.findIndex((q) => q.track.id === fillerUriRef.current); + if (fillerIndex >= 0) position = fillerIndex; + } + // DJ picks pass attribute:false - they are the algorithm choice, not a person's. + const attribute = opts?.attribute !== false; + if (isProgram(item)) { + const rid = item.resource?.id as SonosItemId | undefined; + const iid = typeof item.id === 'object' ? (item.id as SonosItemId) : undefined; + const r = (await window.sonos.loadContent({ + type: item.resource?.type ?? item.type ?? 'PROGRAM', + id: { + objectId: rid?.objectId ?? iid?.objectId, + accountId: rid?.accountId ?? iid?.accountId, + serviceId: rid?.serviceId ?? iid?.serviceId, + }, + playbackAction: 'PLAY', + playModes: { shuffle: false }, + defaults: item.resource?.defaults ?? undefined, + queueAction: 'REPLACE', + })) as { error?: string } | null; + if (r && 'error' in r && r.error) showToast('Play failed: ' + r.error); + else reloadQueue(); + return; + } + + const normalized = normalizeForQueue(item); + const isSingleTrack = isTrack(item); + const isAlbumItem = !isSingleTrack && isAlbum(item); + const isPlaylistItem = !isSingleTrack && isPlaylist(item); + + if (isSingleTrack) { + const insertIndex = position === -1 ? Infinity : position; + const normalizedQueueItem = sonosItemToNormalizedQueueItem(normalized, insertIndex === Infinity ? 0 : insertIndex); + setQueueItems((prev) => { + if (insertIndex === Infinity || insertIndex >= prev.length) { + return [...prev, { ...normalizedQueueItem, index: prev.length }]; + } + const next = [...prev]; + next.splice(insertIndex, 0, { ...normalizedQueueItem, index: insertIndex }); + return next.map((it, i) => ({ ...it, index: i })); + }); + + const tid = normalized.track?.id as SonosItemId | undefined; + if (tid?.objectId && tid?.serviceId && tid?.accountId) { + queryClient.prefetchQuery(trackQueryOptions(tid.objectId, tid.serviceId, tid.accountId)); + } + } + + const rid = normalized.resource?.id; + const iid = typeof normalized.id === 'object' ? normalized.id : undefined; + const body = { + id: { + objectId: rid?.objectId ?? iid?.objectId, + serviceId: rid?.serviceId ?? iid?.serviceId, + accountId: (rid?.accountId ?? iid?.accountId)?.replace(/^sn_/, ''), + }, + type: (normalized.type ?? normalized.resource?.type ?? 'TRACK').replace(/^ITEM_/, ''), + }; + let result = await api.queue.add(body, { + queueId: queueIdRef.current ?? undefined, + ifMatch: queueVersionRef.current ?? undefined, + position, + }); + + let retried = false; + if (result.error) { + // Stale etag - refresh the queue (updates queueVersionRef via onEtag) then retry once. + retried = true; + await reloadQueueRaw(); + result = await api.queue.add(body, { + queueId: queueIdRef.current ?? undefined, + ifMatch: queueVersionRef.current ?? undefined, + position, + }); + } + + if (result.error) { + showToast('Add failed: ' + result.error); + void window.sonos.trackEvent('queue_add', { success: 'false', itemType: body.type }); + return; + } + if (result.etag) queueVersionRef.current = result.etag; + void window.sonos.trackEvent('queue_add', { success: 'true', itemType: body.type }); + + const uri = body.id.objectId; + const serviceId = body.id.serviceId ?? ''; + const accountId = body.id.accountId ?? ''; + + if (attribute && isSingleTrack && uri) { + publishTrackAttribution(uri, serviceId, accountId, { + trackName: getName(normalized), + // Artist powers the title+artist content-key fallback (issue #84). + artist: sonosItemToNormalizedQueueItem(normalized, 0).track.artist || undefined, + imageUrl: getItemArt(item) ?? undefined, + }); + } else if (attribute && isAlbumItem && uri) { + const { albumId, serviceId: aSvc, accountId: aAcc, defaults } = resolveAlbumParams(item); + if (albumId && aSvc && aAcc) { + // Album-level event first (card data as fallback); fan-out track events follow async. + window.sonos + .publishQueued({ + eventType: 'album', + uri, + trackName: getName(item), + artist: ((item as Record)['subtitle'] as string) || (item.artists?.[0]?.name ?? ''), + serviceId: aSvc, + accountId: aAcc, + album: getName(item), + albumId: uri, + imageUrl: getItemArt(item) ?? undefined, + }) + .catch(() => {}); + queryClient + .fetchQuery(albumQueryOptions(albumId, aSvc, aAcc, defaults)) + .then((album) => { + const artistId = (album.artistItem?.resource?.id as SonosItemId | undefined)?.objectId; + fanOutTrackAttribution(album.tracks, { + album: album.title, + albumId: uri, + artist: album.artist, + artistId, + artUrl: album.artUrl, + serviceId: aSvc, + accountId: aAcc, + }); + }) + .catch(() => { + /* silent - album event already published above */ + }); + } + } else if (attribute && isPlaylistItem) { + // Playlists have no album rollup - fan out per-track only. + const { albumId: pid, serviceId: pSvc, accountId: pAcc, defaults } = resolveAlbumParams(item); + if (pid && pSvc && pAcc) { + const doFanOut = () => + queryClient + .fetchQuery(playlistQueryOptions(pid, pSvc, pAcc, defaults)) + .then((tracks) => fanOutTrackAttribution(tracks, { serviceId: pSvc, accountId: pAcc })); + doFanOut().catch(() => { + setTimeout(() => doFanOut().catch(() => {}), 3000); + }); + } + } + + if (!isSingleTrack || retried) { + reloadQueue(); + setTimeout(reloadQueue, 1500); + } + }, + [ + queryClient, + setQueueItems, + reloadQueue, + reloadQueueRaw, + queueIdRef, + queueVersionRef, + showToast, + publishTrackAttribution, + fanOutTrackAttribution, + ], + ); + + // Autoplay: the coordinator picks, this client enqueues only when it holds the lease. + const autoplay = useAutoplay({ + groupId: activeGroupId, + items: queueItems, + nowPlayingUri: playback.currentObjectId, + nowPlayingQueueItemId: playback.queueItemId, + onEnqueue: async (track) => { + await addToQueue( + { + title: track.trackName, + type: 'ITEM_TRACK', + subtitle: track.artist, + imageUrl: track.imageUrl, + resource: { + type: 'TRACK', + id: { objectId: track.uri, serviceId: track.serviceId ?? '', accountId: track.accountId ?? '' }, + }, + } as SonosItem, + -1, + { attribute: false, djFiller: true }, + ); + }, + }); + + useEffect(() => { + queueItemsRef.current = queueItems; + }, [queueItems]); + + useEffect(() => { + fillerUriRef.current = autoplay.fillerUri; + }, [autoplay.fillerUri]); + + return { addToQueue, autoplay }; +} diff --git a/apps/mobile/src/logic/useRemote.ts b/apps/mobile/src/logic/useRemote.ts new file mode 100644 index 0000000..1a6662a --- /dev/null +++ b/apps/mobile/src/logic/useRemote.ts @@ -0,0 +1,118 @@ +// The core of desktop's MainApp (renderer/src/App.tsx) as a platform-neutral hook: auth, groups, +// active-group selection, playback, queue and the reload triggers. The remaining MainApp pieces +// (add-to-queue + attribution, autoplay, layout) live in their own modules / the UI layer. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useToast } from './context/ToastContext'; +import { useAuth } from './hooks/useAuth'; +import { useGroups } from './hooks/useGroups'; +import { usePlayback } from './hooks/usePlayback'; +import { usePrefetchNextLyrics } from './hooks/usePrefetchNextLyrics'; +import { useQueue } from './hooks/useQueue'; +import { useEnsureFavourites } from './hooks/usePlaylists'; +import { getActiveProvider } from './providers'; + +export function useRemote() { + const { showToast } = useToast(); + const isAuthed = useAuth(); + const groups = useGroups(); + const [activeGroupId, setActiveGroupId] = useState(null); + + const { playback, applyGroupCache, queueIdRef, queueVersionRef } = usePlayback(activeGroupId); + const { + items: queueItems, + setItems: setQueueItems, + isLoading: queueLoading, + error: queueError, + reload: reloadQueueRaw, + } = useQueue( + isAuthed, + activeGroupId, + playback.queueId, + (etag) => { + queueVersionRef.current = etag; + }, + (msg) => showToast(`Queue refresh failed: ${msg}`, 'error'), + ); + usePrefetchNextLyrics(queueItems, playback.queueItemId); + + const reloadQueue = useCallback(() => { + reloadQueueRaw(); + window.sonos.refreshAttribution().catch(() => {}); + }, [reloadQueueRaw]); + + // Reload whenever Sonos reports a new queue version - catches external adds/removes/reorders. + const seenQueueVersionRef = useRef(null); + useEffect(() => { + const v = playback.queueVersion; + if (!v || v === seenQueueVersionRef.current) return; + seenQueueVersionRef.current = v; + reloadQueue(); + }, [playback.queueVersion, reloadQueue]); + + // Reload as soon as the WS session is up - the first queue load can fire before it is established. + useEffect(() => window.sonos.onWsReady(() => reloadQueue()), [reloadQueue]); + + const [displayName, setDisplayName] = useState(undefined); // undefined = not yet loaded + useEnsureFavourites(displayName); + useEffect(() => { + window.sonos.getDisplayName().then(setDisplayName).catch(() => setDisplayName(null)); + return window.sonos.onEntraReady(() => { + window.sonos.getDisplayName().then(setDisplayName).catch(() => {}); + }); + }, []); + + // Pick the saved group (matched by stable coordinatorId), else the first. + useEffect(() => { + if (groups.length === 0 || activeGroupId) return; + getActiveProvider() + .getActiveGroup() + .then((savedCoordinatorId) => { + const match = savedCoordinatorId ? groups.find((g) => g.coordinatorId === savedCoordinatorId) : null; + setActiveGroupId(match ? match.id : groups[0].id); + }) + .catch(() => setActiveGroupId(groups[0].id)); + }, [groups, activeGroupId]); + + const changeGroup = useCallback( + (groupId: string) => { + setActiveGroupId(groupId); + getActiveProvider().setGroup(groupId); + applyGroupCache(groupId); + window.sonos.refreshAttribution().catch(() => {}); + window.sonos.trackEvent('group_changed').catch(() => {}); + }, + [applyGroupCache], + ); + + /** Desktop does this on window focus; mobile calls it when the app returns to the foreground. */ + const refreshPlayback = useCallback(() => { + window.sonos.refreshPlayback().catch(() => {}); + }, []); + + const activeGroup = groups.find((g) => g.id === activeGroupId) ?? null; + + return { + isAuthed, + groups, + activeGroupId, + activeGroup, + changeGroup, + playback, + queue: { + items: queueItems, + setItems: setQueueItems, + isLoading: queueLoading, + error: queueError, + reload: reloadQueue, + reloadRaw: reloadQueueRaw, + }, + queueIdRef, + queueVersionRef, + displayName, + setDisplayName, + refreshPlayback, + }; +} + +export type Remote = ReturnType; diff --git a/apps/mobile/src/office/__tests__/azureApi.test.ts b/apps/mobile/src/office/__tests__/azureApi.test.ts new file mode 100644 index 0000000..318fad5 --- /dev/null +++ b/apps/mobile/src/office/__tests__/azureApi.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAzureApi } from '../azureApi'; + +const BASE = 'https://fn.test'; +const JSON_H = { 'Content-Type': 'application/json' }; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +let fetchMock: ReturnType; +let config: { displayName?: string; entraOid?: string }; +let onChanged: ReturnType; +let api: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(async () => json({ ok: 1 })); + config = { displayName: 'Rich', entraOid: 'oid-1' }; + onChanged = vi.fn(); + api = createAzureApi({ + baseUrl: BASE, + config, + fetchImpl: fetchMock as unknown as typeof fetch, + onDisplayNameChanged: onChanged as unknown as (n: string) => void, + }); +}); + +const buf = new ArrayBuffer(4); + +type Case = [string, () => Promise, string, RequestInit | undefined]; + +function cases(): Case[] { + return [ + ['fetchPlaylists owner+member', () => api.fetchPlaylists({ owner: 'a b', member: 'c' }), `${BASE}/api/playlists?owner=a%20b&member=c`, undefined], + ['fetchPlaylists empty', () => api.fetchPlaylists({}), `${BASE}/api/playlists?`, undefined], + ['fetchPlaylist', () => api.fetchPlaylist('p/1'), `${BASE}/api/playlist/p%2F1`, undefined], + ['createPlaylist', () => api.createPlaylist('Mix', true), `${BASE}/api/playlists`, + { method: 'POST', headers: JSON_H, body: JSON.stringify({ name: 'Mix', isPublic: true, owner: 'Rich' }) }], + ['updatePlaylist', () => api.updatePlaylist('p1', { name: 'N' }), `${BASE}/api/playlist/p1`, + { method: 'PUT', headers: JSON_H, body: JSON.stringify({ userName: 'Rich', name: 'N' }) }], + ['deletePlaylist', () => api.deletePlaylist('p1'), `${BASE}/api/playlist/p1`, + { method: 'DELETE', headers: JSON_H, body: JSON.stringify({ userName: 'Rich' }) }], + ['addTrackToPlaylist', () => api.addTrackToPlaylist('p1', { uri: 'u' }), `${BASE}/api/playlist/p1/tracks`, + { method: 'POST', headers: JSON_H, body: JSON.stringify({ track: { uri: 'u' }, userName: 'Rich' }) }], + ['removeTrackFromPlaylist', () => api.removeTrackFromPlaylist('p1', 'u'), `${BASE}/api/playlist/p1/tracks`, + { method: 'DELETE', headers: JSON_H, body: JSON.stringify({ userName: 'Rich', uri: 'u' }) }], + ['reorderPlaylistTracks', () => api.reorderPlaylistTracks('p1', 1, 3), `${BASE}/api/playlist/p1/tracks`, + { method: 'PATCH', headers: JSON_H, body: JSON.stringify({ userName: 'Rich', fromIndex: 1, toIndex: 3 }) }], + ['joinPlaylist', () => api.joinPlaylist('p1', 'leave'), `${BASE}/api/playlist/p1/members`, + { method: 'POST', headers: JSON_H, body: JSON.stringify({ userName: 'Rich', action: 'leave' }) }], + ['uploadPlaylistImage', () => api.uploadPlaylistImage('p1', buf, 'image/png', 'R&B'), `${BASE}/api/playlist/p1/image?userName=R%26B`, + { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: buf }], + ['ensureFavourites', () => api.ensureFavourites(), `${BASE}/api/profile/Rich/favourites`, { method: 'POST' }], + ['fetchUsers default', () => api.fetchUsers(), `${BASE}/api/users?exclude=Rich`, undefined], + ['fetchUsers includeSelf', () => api.fetchUsers(true), `${BASE}/api/users`, undefined], + ['fetchUserProfile', () => api.fetchUserProfile('A B'), `${BASE}/api/profile/A%20B`, undefined], + ['uploadProfileImage', () => api.uploadProfileImage('Rich', buf, 'image/jpeg'), `${BASE}/api/profile/Rich/image`, + { method: 'POST', headers: { 'Content-Type': 'image/jpeg' }, body: buf }], + ['renameUser', () => api.renameUser('Rich', 'Rick'), `${BASE}/api/profile/rename`, + { method: 'POST', headers: JSON_H, body: JSON.stringify({ oid: 'oid-1', oldName: 'Rich', newName: 'Rick' }) }], + ['fetchStats period only', () => api.fetchStats('week'), `${BASE}/api/stats?period=week`, undefined], + ['fetchStats all', () => api.fetchStats('week', 'u 1', 5), `${BASE}/api/stats?period=week&userId=u%201&count=5`, undefined], + ['djAutoplay', () => api.djAutoplay({ a: 1 }), `${BASE}/api/dj/autoplay`, + { method: 'POST', headers: JSON_H, body: '{"a":1}' }], + ['djAutoplay undefined body', () => api.djAutoplay(undefined), `${BASE}/api/dj/autoplay`, + { method: 'POST', headers: JSON_H, body: '{}' }], + ['fetchDjSet all', () => api.fetchDjSet({ users: ['a', 'b'], limit: 10, excludeUris: ['x', 'y'] }), `${BASE}/api/dj?users=a%2Cb&limit=10&exclude=x%2Cy`, undefined], + ['fetchDjSet none', () => api.fetchDjSet(), `${BASE}/api/dj?`, undefined], + ['fetchRecentlyPlayed', () => api.fetchRecentlyPlayed('u 1'), `${BASE}/api/recently-played?userId=u%201`, undefined], + ['fetchDailyGame default', () => api.fetchDailyGame(), `${BASE}/api/game?date=today`, undefined], + ['fetchDailyGame empty', () => api.fetchDailyGame(''), `${BASE}/api/game?date=today`, undefined], + ['fetchDailyGame date', () => api.fetchDailyGame('2026-01-02'), `${BASE}/api/game?date=2026-01-02`, undefined], + ['submitGameScore', () => api.submitGameScore({ gameId: 'g', userName: 'Rich', guesses: { main: ['left'], bonus: [] } }), `${BASE}/api/score`, + { method: 'POST', headers: JSON_H, body: JSON.stringify({ gameId: 'g', userName: 'Rich', guesses: { main: ['left'], bonus: [] } }) }], + ['fetchGameLeaderboard', () => api.fetchGameLeaderboard(), `${BASE}/api/game-leaderboard?date=today`, undefined], + ['fetchGameDates', () => api.fetchGameDates('A B'), `${BASE}/api/game-dates?userName=A%20B`, undefined], + ['fetchMyScore', () => api.fetchMyScore('g 1', 'A B'), `${BASE}/api/my-score?gameId=g%201&userName=A%20B`, undefined], + ['fetchGameStats', () => api.fetchGameStats('2026-01-02'), `${BASE}/api/game-stats?date=2026-01-02`, undefined], + ['claimDisplayName', () => api.claimDisplayName('o', 'N'), `${BASE}/api/profile/claim`, + { method: 'POST', headers: JSON_H, body: JSON.stringify({ oid: 'o', displayName: 'N' }) }], + ['lookupProfileByOid', () => api.lookupProfileByOid('o/1'), `${BASE}/api/profile/lookup?oid=o%2F1`, undefined], + ]; +} + +describe('azureApi request shapes', () => { + for (const [name, call, url, init] of cases()) { + it(name, async () => { + // rebuild via factory so `api` from beforeEach is used at call time + await call(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [gotUrl, gotInit] = fetchMock.mock.calls[0] as [string, RequestInit | undefined]; + expect(gotUrl).toBe(url); + expect(gotInit).toEqual(init); + }); + } +}); + +describe('error handling', () => { + it('GET helpers return { error } on network failure or bad JSON', async () => { + fetchMock.mockRejectedValueOnce(new Error('boom')); + expect(await api.fetchStats('week')).toEqual({ error: 'Error: boom' }); + fetchMock.mockResolvedValueOnce(new Response('', { status: 200 })); + expect(await api.fetchPlaylist('p')).toEqual({ error: expect.stringContaining('SyntaxError') }); + }); + + it('upload / dj methods return { error } on failure', async () => { + fetchMock.mockRejectedValue(new Error('x')); + expect(await api.uploadPlaylistImage('p', buf, 'i/png', 'u')).toEqual({ error: 'Error: x' }); + expect(await api.uploadProfileImage('u', buf, 'i/png')).toEqual({ error: 'Error: x' }); + expect(await api.djAutoplay({})).toEqual({ error: 'Error: x' }); + expect(await api.fetchDailyGame()).toEqual({ error: 'Error: x' }); + expect(await api.submitGameScore({} as never)).toEqual({ error: 'Error: x' }); + }); + + it('playlist mutations throw with server error message or status', async () => { + fetchMock.mockResolvedValueOnce(json({ error: 'nope' }, 403)); + await expect(api.deletePlaylist('p')).rejects.toThrow('nope'); + fetchMock.mockResolvedValueOnce(new Response('oops', { status: 500 })); + await expect(api.joinPlaylist('p', 'join')).rejects.toThrow('Server error 500'); + fetchMock.mockResolvedValueOnce(json({ id: 'p' }, 201)); + await expect(api.createPlaylist('n', false)).resolves.toEqual({ id: 'p' }); + }); + + it('ensureFavourites throws without a display name', async () => { + config.displayName = undefined; + await expect(api.ensureFavourites()).rejects.toThrow('No display name set'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('deletePlaylist sends empty userName when no display name', async () => { + config.displayName = undefined; + await api.deletePlaylist('p'); + expect((fetchMock.mock.calls[0][1] as RequestInit).body).toBe('{"userName":""}'); + }); + + it('fetchUsers filters non-arrays and errors to []', async () => { + fetchMock.mockResolvedValueOnce(json({ error: 'x' })); + expect(await api.fetchUsers()).toEqual([]); + fetchMock.mockRejectedValueOnce(new Error('x')); + expect(await api.fetchUsers()).toEqual([]); + fetchMock.mockResolvedValueOnce(json([{ userId: 'a' }])); + expect(await api.fetchUsers()).toEqual([{ userId: 'a' }]); + }); +}); + +describe('special status handling', () => { + it('fetchDailyGame maps 404/202 to pending', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 404 })); + expect(await api.fetchDailyGame()).toEqual({ status: 'pending' }); + fetchMock.mockResolvedValueOnce(new Response('', { status: 202 })); + expect(await api.fetchDailyGame()).toEqual({ status: 'pending' }); + fetchMock.mockResolvedValueOnce(json({ id: 'g' })); + expect(await api.fetchDailyGame()).toEqual({ id: 'g' }); + }); + + it('submitGameScore maps 409 / errors / success', async () => { + fetchMock.mockResolvedValueOnce(json({ existing: { s: 1 } }, 409)); + expect(await api.submitGameScore({} as never)).toEqual({ duplicate: true, existing: { s: 1 } }); + fetchMock.mockResolvedValueOnce(json({ error: 'bad' }, 400)); + expect(await api.submitGameScore({} as never)).toEqual({ error: 'bad' }); + fetchMock.mockResolvedValueOnce(json({}, 500)); + expect(await api.submitGameScore({} as never)).toEqual({ error: 'HTTP 500' }); + fetchMock.mockResolvedValueOnce(json({ points: 3 })); + expect(await api.submitGameScore({} as never)).toEqual({ ok: true, score: { points: 3 } }); + }); + + it('claimDisplayName: true only on 409; network failure allows', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 409 })); + expect(await api.claimDisplayName('o', 'n')).toBe(true); + fetchMock.mockResolvedValueOnce(new Response('', { status: 200 })); + expect(await api.claimDisplayName('o', 'n')).toBe(false); + fetchMock.mockRejectedValueOnce(new Error('x')); + expect(await api.claimDisplayName('o', 'n')).toBe(false); + }); + + it('lookupProfileByOid handles 404, non-ok, missing name, errors', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 404 })); + expect(await api.lookupProfileByOid('o')).toBeNull(); + fetchMock.mockResolvedValueOnce(new Response('', { status: 500 })); + expect(await api.lookupProfileByOid('o')).toBeNull(); + fetchMock.mockResolvedValueOnce(json({})); + expect(await api.lookupProfileByOid('o')).toBeNull(); + fetchMock.mockRejectedValueOnce(new Error('x')); + expect(await api.lookupProfileByOid('o')).toBeNull(); + fetchMock.mockResolvedValueOnce(json({ displayName: 'Rich' })); + expect(await api.lookupProfileByOid('o')).toBe('Rich'); + }); +}); + +describe('setDisplayName / renameUser', () => { + it('setDisplayName returns taken without mutating config', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 409 })); + expect(await api.setDisplayName('Bob')).toEqual({ error: 'taken' }); + expect(config.displayName).toBe('Rich'); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it('setDisplayName claims then updates config and notifies', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 200 })); + expect(await api.setDisplayName('Bob')).toBeNull(); + expect(config.displayName).toBe('Bob'); + expect(onChanged).toHaveBeenCalledWith('Bob'); + }); + + it('setDisplayName skips claim for empty name or no oid', async () => { + expect(await api.setDisplayName('')).toBeNull(); + config.entraOid = undefined; + expect(await api.setDisplayName('Bob')).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(config.displayName).toBe('Bob'); + }); + + it('renameUser status mapping', async () => { + config.entraOid = undefined; + expect(await api.renameUser('a', 'b')).toEqual({ error: 'not-authed' }); + config.entraOid = 'oid-1'; + fetchMock.mockResolvedValueOnce(new Response('', { status: 409 })); + expect(await api.renameUser('a', 'b')).toEqual({ error: 'taken' }); + fetchMock.mockResolvedValueOnce(new Response('', { status: 403 })); + expect(await api.renameUser('a', 'b')).toEqual({ error: 'not-owner' }); + fetchMock.mockResolvedValueOnce(new Response('', { status: 500 })); + expect(await api.renameUser('a', 'b')).toEqual({ error: 'server error 500' }); + fetchMock.mockRejectedValueOnce(new Error('x')); + expect(await api.renameUser('a', 'b')).toEqual({ error: 'Error: x' }); + expect(config.displayName).toBe('Rich'); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it('renameUser success updates config and notifies', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 200 })); + expect(await api.renameUser('Rich', 'Rick')).toEqual({ ok: true }); + expect(config.displayName).toBe('Rick'); + expect(onChanged).toHaveBeenCalledWith('Rick'); + }); +}); diff --git a/apps/mobile/src/office/__tests__/config.test.ts b/apps/mobile/src/office/__tests__/config.test.ts new file mode 100644 index 0000000..b6f122c --- /dev/null +++ b/apps/mobile/src/office/__tests__/config.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest'; +import { loadConfig, saveConfig } from '../config'; +import type { AppConfig, KeyValueStore } from '../config'; + +function memStore(initial: Record = {}): KeyValueStore & { data: Record } { + const data = { ...initial }; + return { + data, + get: async (k) => data[k] ?? null, + set: async (k, v) => { + data[k] = v; + }, + remove: async (k) => { + delete data[k]; + }, + }; +} + +describe('config', () => { + it('round-trips only the persisted fields', async () => { + const store = memStore(); + const cfg: AppConfig = { + displayName: 'Rich', + entraOid: 'oid', + entraName: 'Richard', + entraEmail: 'r@x.co', + lastSeenVersion: '1.2.3', + preferredCoordinatorId: 'RINCON_1', + queueDockedWidth: 320, + householdId: 'hh', // runtime-discovered: must not persist + }; + await saveConfig(store, cfg); + expect(Object.keys(JSON.parse(store.data.config)).sort()).toEqual( + ['displayName', 'entraEmail', 'entraName', 'entraOid', 'lastSeenVersion', 'preferredCoordinatorId', 'queueDockedWidth'], + ); + expect(store.data.config).not.toContain('householdId'); + + const loaded: AppConfig = {}; + await loadConfig(store, loaded); + expect(loaded).toEqual({ + displayName: 'Rich', + entraOid: 'oid', + entraName: 'Richard', + entraEmail: 'r@x.co', + lastSeenVersion: '1.2.3', + preferredCoordinatorId: 'RINCON_1', + queueDockedWidth: 320, + }); + }); + + it('type-checks each field on load and ignores unknown ones', async () => { + const store = memStore({ + config: JSON.stringify({ displayName: 5, entraOid: 'o', queueDockedWidth: '300', lastSeenVersion: null, householdId: 'x' }), + }); + const cfg: AppConfig = {}; + await loadConfig(store, cfg); + expect(cfg).toEqual({ entraOid: 'o' }); + }); + + it('does not throw on missing, corrupt or throwing stores', async () => { + const cfg: AppConfig = {}; + await expect(loadConfig(memStore(), cfg)).resolves.toBeUndefined(); + await expect(loadConfig(memStore({ config: '{nope' }), cfg)).resolves.toBeUndefined(); + await expect(loadConfig(memStore({ config: 'null' }), cfg)).resolves.toBeUndefined(); + const bad: KeyValueStore = { + get: async () => { + throw new Error('x'); + }, + set: async () => { + throw new Error('y'); + }, + remove: async () => {}, + }; + await expect(loadConfig(bad, cfg)).resolves.toBeUndefined(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await expect(saveConfig(bad, cfg)).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + expect(cfg).toEqual({}); + }); +}); diff --git a/apps/mobile/src/office/__tests__/genius.test.ts b/apps/mobile/src/office/__tests__/genius.test.ts new file mode 100644 index 0000000..82f9122 --- /dev/null +++ b/apps/mobile/src/office/__tests__/genius.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createGenius } from '../genius'; + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +function setup(key: string | undefined | null = 'k') { + const fetchImpl = vi.fn(); + const log = vi.fn(); + const g = createGenius({ getKey: () => key ?? undefined, fetchImpl: fetchImpl as unknown as typeof fetch, log }); + return { g, fetchImpl, log }; +} + +const auth = { headers: { Authorization: 'Bearer k' } }; + +describe('genius', () => { + it('returns null without a key, without fetching', async () => { + const { g, fetchImpl, log } = setup(null); + expect(await g.description('t', 'a')).toBeNull(); + expect(await g.artist('a')).toBeNull(); + expect(await g.albumYear('al', 'a')).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledTimes(3); + }); + + describe('description', () => { + it('searches then fetches the song', async () => { + const { g, fetchImpl } = setup(); + fetchImpl + .mockResolvedValueOnce(json({ response: { hits: [{ result: { id: 42 } }] } })) + .mockResolvedValueOnce(json({ response: { song: { description: { dom: { tag: 'root' } } } } })); + expect(await g.description('Song', 'Art ist')).toEqual({ tag: 'root' }); + expect(fetchImpl.mock.calls[0]).toEqual(['https://api.genius.com/search?q=Song%20Art%20ist', auth]); + expect(fetchImpl.mock.calls[1]).toEqual(['https://api.genius.com/songs/42', auth]); + }); + + it('returns null on no hits, non-ok, missing dom, bad json, or network error', async () => { + const { g, fetchImpl, log } = setup(); + fetchImpl.mockResolvedValueOnce(json({ response: { hits: [] } })); + expect(await g.description('a', 'b')).toBeNull(); + fetchImpl.mockResolvedValueOnce(json({}, 500)); + expect(await g.description('a', 'b')).toBeNull(); + fetchImpl + .mockResolvedValueOnce(json({ response: { hits: [{ result: { id: 1 } }] } })) + .mockResolvedValueOnce(json({}, 404)); + expect(await g.description('a', 'b')).toBeNull(); + fetchImpl + .mockResolvedValueOnce(json({ response: { hits: [{ result: { id: 1 } }] } })) + .mockResolvedValueOnce(json({ response: { song: {} } })); + expect(await g.description('a', 'b')).toBeNull(); + fetchImpl.mockResolvedValueOnce(new Response('nope')); + expect(await g.description('a', 'b')).toBeNull(); + expect(log).toHaveBeenCalledWith(expect.stringContaining('[genius] error:')); + fetchImpl.mockRejectedValueOnce(new Error('x')); + expect(await g.description('a', 'b')).toBeNull(); + }); + }); + + describe('artist', () => { + const hit = (id: number, name: string) => ({ result: { primary_artist: { id, name } } }); + + it('prefers the hit whose artist name matches, and maps the payload', async () => { + const { g, fetchImpl } = setup(); + fetchImpl + .mockResolvedValueOnce(json({ response: { hits: [hit(1, 'Other'), hit(2, 'The Artist')] } })) + .mockResolvedValueOnce( + json({ + response: { + artist: { + description: { dom: 'd' }, + alternate_names: ['x'], + image_url: 'i', + header_image_url: null, + instagram_name: 'ig', + twitter_name: null, + }, + }, + }), + ); + expect(await g.artist('artist', 'Hint')).toEqual({ + description: 'd', + alternateNames: ['x'], + imageUrl: 'i', + headerImageUrl: null, + instagram: 'ig', + twitter: null, + }); + expect(fetchImpl.mock.calls[0][0]).toBe('https://api.genius.com/search?q=Hint%20artist'); + expect(fetchImpl.mock.calls[1][0]).toBe('https://api.genius.com/artists/2'); + }); + + it('falls back to the first hit, defaults missing fields, and nulls on failures', async () => { + const { g, fetchImpl } = setup(); + fetchImpl + .mockResolvedValueOnce(json({ response: { hits: [hit(9, 'Zed')] } })) + .mockResolvedValueOnce(json({ response: { artist: {} } })); + expect(await g.artist('nomatch')).toEqual({ + description: null, + alternateNames: [], + imageUrl: null, + headerImageUrl: null, + instagram: null, + twitter: null, + }); + expect(fetchImpl.mock.calls[0][0]).toBe('https://api.genius.com/search?q=nomatch'); + fetchImpl.mockResolvedValueOnce(json({ response: { hits: [] } })); + expect(await g.artist('a')).toBeNull(); + fetchImpl.mockResolvedValueOnce(json({}, 401)); + expect(await g.artist('a')).toBeNull(); + fetchImpl + .mockResolvedValueOnce(json({ response: { hits: [hit(1, 'a')] } })) + .mockResolvedValueOnce(json({ response: {} })); + expect(await g.artist('a')).toBeNull(); + fetchImpl.mockRejectedValueOnce(new Error('x')); + expect(await g.artist('a')).toBeNull(); + }); + }); + + describe('albumYear', () => { + const hit = (name: string | null, year: number | null) => ({ + result: { primary_artist: name ? { name } : null, release_date_components: { year } }, + }); + + it('returns the year of the matching-artist hit, else the first hit', async () => { + const { g, fetchImpl } = setup(); + fetchImpl.mockResolvedValueOnce(json({ response: { hits: [hit('Other', 1999), hit('Big Band', 2001)] } })); + expect(await g.albumYear('Alb', 'big')).toBe(2001); + expect(fetchImpl.mock.calls[0][0]).toBe('https://api.genius.com/search?q=Alb%20big'); + fetchImpl.mockResolvedValueOnce(json({ response: { hits: [hit(null, 1980)] } })); + expect(await g.albumYear('Alb', 'nomatch')).toBe(1980); + }); + + it('returns null for no hits, null year, non-ok, or errors', async () => { + const { g, fetchImpl } = setup(); + fetchImpl.mockResolvedValueOnce(json({ response: { hits: [] } })); + expect(await g.albumYear('a', 'b')).toBeNull(); + fetchImpl.mockResolvedValueOnce(json({ response: { hits: [hit('b', null)] } })); + expect(await g.albumYear('a', 'b')).toBeNull(); + fetchImpl.mockResolvedValueOnce(json({}, 500)); + expect(await g.albumYear('a', 'b')).toBeNull(); + fetchImpl.mockRejectedValueOnce(new Error('x')); + expect(await g.albumYear('a', 'b')).toBeNull(); + }); + }); +}); diff --git a/apps/mobile/src/office/__tests__/graphPresence.test.ts b/apps/mobile/src/office/__tests__/graphPresence.test.ts new file mode 100644 index 0000000..afc94bc --- /dev/null +++ b/apps/mobile/src/office/__tests__/graphPresence.test.ts @@ -0,0 +1,122 @@ +// Run: npm run test:main — node's built-in runner, no framework needed. +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { resolveRoster, isInOffice, type GraphPresence } from '../graphPresence'; + +/** + * A real Graph response from the TrueNorth tenant, trimmed to the fields we use. + * Keeping the genuine data means the awkward cases stay in the fixture rather + * than being tidied into shapes that happen to suit the code. + */ +const REAL: GraphPresence[] = [ + { id: 'o-elliot', availability: 'Available', workLocation: { workLocationType: 'office', source: 'scheduled' } }, + { id: 'o-rich', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, + { id: 'o-bog', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, + { id: 'o-sam', availability: 'Available', workLocation: { workLocationType: 'remote', source: 'scheduled' } }, + { id: 'o-abbie', availability: 'Available', workLocation: { workLocationType: 'remote', source: 'scheduled' } }, + { id: 'o-alex', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, + { id: 'o-joe', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, + // Rostered into the office but never signed in — the case that matters. + { id: 'o-simrah', availability: 'Offline', workLocation: { workLocationType: 'office', source: 'scheduled' } }, + { id: 'o-steve', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, + { id: 'o-fergus', availability: 'Offline', workLocation: undefined }, + { id: 'o-longalex',availability: 'Available', workLocation: { workLocationType: 'remote', source: 'scheduled' } }, + { id: 'o-aidan', availability: 'Available', workLocation: { workLocationType: 'office', source: 'scheduled' } }, + { id: 'o-josh', availability: 'Available', workLocation: { workLocationType: 'remote', source: 'manual' } }, + { id: 'o-kade', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, + // No Teams licence, or never signed in. Third-heaviest queuer in the office. + { id: 'o-muhamad', availability: 'PresenceUnknown', workLocation: undefined }, +]; + +const NAMES: Record = { + 'o-elliot': 'Elliot', 'o-rich': 'Rich', 'o-bog': 'Bog', 'o-sam': 'Sam', + 'o-abbie': 'Abbie', 'o-alex': 'Alex', 'o-joe': 'Joe Pitts', 'o-simrah': 'Simrah', + 'o-steve': 'Not Steve', 'o-fergus': 'Fergus Westlake', 'o-longalex': 'Long Alex', + 'o-aidan': 'Aidan', 'o-josh': 'Josh', 'o-kade': 'Kade', + 'o-muhamad': 'Muhamad', +}; + +test('builds the room from sensed presence, not availability or intent', () => { + const roster = resolveRoster(REAL, NAMES); + assert.equal(roster.basis, 'sensed'); + // Only the six Teams actually detected on the office network. + assert.deepEqual( + roster.inOffice.sort(), + ['Alex', 'Bog', 'Joe Pitts', 'Kade', 'Not Steve', 'Rich'], + ); +}); + +test('rostered-but-never-detected is treated as absent', () => { + // Elliot and Aidan are Available and scheduled 'office', but Teams never saw + // them on the network — and it saw six colleagues, so detection works here. + const roster = resolveRoster(REAL, NAMES); + for (const name of ['Elliot', 'Aidan']) { + assert.ok(!roster.inOffice.includes(name), `${name} was only rostered`); + } +}); + +test('falls back to rostered intent when nothing is being sensed', () => { + const noSensing = REAL.map((p) => + p.workLocation?.source === 'automatic' + ? { ...p, workLocation: { ...p.workLocation, source: 'scheduled' as const } } + : p, + ); + const roster = resolveRoster(noSensing, NAMES); + assert.equal(roster.basis, 'rostered'); + assert.equal(roster.observed, 0); + // Everyone marked office and signed in — Simrah still excluded, she's Offline. + assert.ok(roster.inOffice.includes('Elliot')); + assert.ok(!roster.inOffice.includes('Simrah')); +}); + +test('a rostered no-show is not in the room', () => { + // Simrah is workLocationType 'office' but Offline — scheduled in, never arrived. + const roster = resolveRoster(REAL, NAMES); + assert.ok(!roster.inOffice.includes('Simrah')); + assert.ok(!isInOffice(REAL.find((p) => p.id === 'o-simrah')!)); +}); + +test('people working from home are excluded even while fully available', () => { + const roster = resolveRoster(REAL, NAMES); + for (const name of ['Sam', 'Abbie', 'Long Alex', 'Josh']) { + assert.ok(!roster.inOffice.includes(name), `${name} is remote today`); + } +}); + +test('counts what was observed', () => { + const roster = resolveRoster(REAL, NAMES); + assert.equal(roster.observed, 6); + assert.equal(roster.inOffice.length, 6); +}); + +/** + * Muhamad was a student placement, returning next year. PresenceUnknown is the + * right answer for him — he isn't in the room. His queue history still feeds the + * recommender; he simply isn't a listener today. + */ +test('someone with no Teams presence is left out rather than guessed at', () => { + const roster = resolveRoster(REAL, NAMES); + assert.ok(!roster.inOffice.includes('Muhamad')); + // They must still appear in the detail so the UI can explain the omission. + assert.ok(roster.detail.some((d) => d.userId === 'Muhamad')); +}); + +test('falls back to availability only when nobody sets a work location', () => { + const noLocation: GraphPresence[] = [ + { id: 'a', availability: 'Available', workLocation: { workLocationType: 'unspecified' } }, + { id: 'b', availability: 'Offline' }, + ]; + const roster = resolveRoster(noLocation, { a: 'Ann', b: 'Ben' }); + assert.equal(roster.basis, 'availability'); + assert.deepEqual(roster.inOffice, ['Ann']); + assert.equal(roster.observed, 0); +}); + +test('reports "none" when there is nothing to go on', () => { + const roster = resolveRoster( + [{ id: 'a', availability: 'Offline' }], + { a: 'Ann' }, + ); + assert.equal(roster.basis, 'none'); + assert.deepEqual(roster.inOffice, []); +}); diff --git a/apps/mobile/src/office/__tests__/officePresence.test.ts b/apps/mobile/src/office/__tests__/officePresence.test.ts new file mode 100644 index 0000000..a4d9d2b --- /dev/null +++ b/apps/mobile/src/office/__tests__/officePresence.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createOfficePresence } from '../officePresence'; + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +function setup(opts: { auth?: boolean; token?: string | null; fetchImpl?: ReturnType } = {}) { + const fetchImpl = opts.fetchImpl ?? vi.fn(); + const svc = createOfficePresence({ + baseUrl: 'https://fn.test', + hasAuth: () => opts.auth ?? true, + getGraphToken: () => Promise.resolve(opts.token === undefined ? 'tok' : opts.token), + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + return { svc, fetchImpl }; +} + +describe('fetchOfficePresence', () => { + it('reports not signed in', async () => { + const { svc, fetchImpl } = setup({ auth: false }); + expect(await svc.fetchOfficePresence()).toEqual({ inOffice: [], basis: 'none', detail: [], error: 'not signed in' }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('reports missing consent', async () => { + const { svc } = setup({ token: null }); + expect(await svc.fetchOfficePresence()).toEqual({ inOffice: [], basis: 'none', detail: [], error: 'no presence consent' }); + }); + + it('returns none when no users have an OID', async () => { + const { svc, fetchImpl } = setup(); + fetchImpl.mockResolvedValueOnce(json([{ userId: 'a', entraOid: null }, { userId: 'b' }])); + expect(await svc.fetchOfficePresence()).toEqual({ inOffice: [], basis: 'none', detail: [] }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('resolves the roster from Graph presences', async () => { + const { svc, fetchImpl } = setup(); + fetchImpl + .mockResolvedValueOnce(json([{ userId: 'Alice', entraOid: 'o1' }, { userId: 'Bob', entraOid: 'o2' }])) + .mockResolvedValueOnce( + json({ + value: [ + { id: 'o1', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, + { id: 'o2', availability: 'Available', workLocation: { workLocationType: 'remote', source: 'manual' } }, + ], + }), + ); + const r = (await svc.fetchOfficePresence()) as { inOffice: string[]; basis: string; observed: number }; + expect(r.inOffice).toEqual(['Alice']); + expect(r.basis).toBe('sensed'); + expect(r.observed).toBe(1); + + const [url, init] = fetchImpl.mock.calls[1] as [string, RequestInit]; + expect(url).toBe('https://graph.microsoft.com/v1.0/communications/getPresencesByUserId'); + expect(init.method).toBe('POST'); + expect((init.headers as Record).Authorization).toBe('Bearer tok'); + expect(JSON.parse(init.body as string)).toEqual({ ids: ['o1', 'o2'] }); + }); + + it('wraps thrown errors', async () => { + const { svc, fetchImpl } = setup(); + fetchImpl.mockRejectedValueOnce(new Error('down')); + expect(await svc.fetchOfficePresence()).toEqual({ inOffice: [], basis: 'none', detail: [], error: 'Error: down' }); + + fetchImpl + .mockResolvedValueOnce(json([{ userId: 'A', entraOid: 'o1' }])) + .mockResolvedValueOnce(new Response('denied', { status: 403 })); + const r = (await svc.fetchOfficePresence()) as { error: string }; + expect(r.error).toContain('Graph presence 403: denied'); + }); +}); diff --git a/apps/mobile/src/office/__tests__/pubsub.test.ts b/apps/mobile/src/office/__tests__/pubsub.test.ts new file mode 100644 index 0000000..00c92a1 --- /dev/null +++ b/apps/mobile/src/office/__tests__/pubsub.test.ts @@ -0,0 +1,300 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { OfficePubSub } from '../pubsub'; +import type { AttributionEvent, PubSubSocket } from '../pubsub'; + +class FakeSocket implements PubSubSocket { + readyState = 0; + sent: Array> = []; + closed = false; + onopen: (() => void) | null = null; + onmessage: ((e: { data: unknown }) => void) | null = null; + onerror: ((e: { message?: string }) => void) | null = null; + onclose: (() => void) | null = null; + constructor(public url: string, public protocol: string) {} + send(d: string) { + this.sent.push(JSON.parse(d)); + } + close() { + this.closed = true; + this.readyState = 3; + this.onclose?.(); + } + open() { + this.readyState = 1; + this.onopen?.(); + } + drop() { + this.readyState = 3; + this.onclose?.(); + } + push(msg: unknown) { + this.onmessage?.({ data: JSON.stringify(msg) }); + } +} + +const FN = 'https://fn.test'; +const res = (body: string, status = 200) => new Response(body, { status }); + +let sockets: FakeSocket[]; +let fetchMock: ReturnType; +let log: ReturnType & ((...a: unknown[]) => void); +let ps: OfficePubSub; +let attribution: string; +let negotiateStatus: number; +let logStatuses: number[]; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + sockets = []; + attribution = '{}'; + negotiateStatus = 200; + logStatuses = []; + log = vi.fn() as typeof log; + fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (url.includes('/api/connect')) { + return negotiateStatus >= 400 + ? res('', negotiateStatus) + : res(JSON.stringify({ webPubSubUrl: `wss://wps.test/${sockets.length}` })); + } + if (url.endsWith('/api/attribution')) return res(attribution, attribution === '' ? 404 : 200); + if (url.endsWith('/api/log-event')) return res('', logStatuses.shift() ?? 200); + throw new Error(`unexpected ${init?.method ?? 'GET'} ${url}`); + }); + ps = new OfficePubSub({ + createSocket: (u, p) => { + const s = new FakeSocket(u, p); + sockets.push(s); + return s; + }, + fetchImpl: fetchMock as unknown as typeof fetch, + log, + }); +}); + +afterEach(() => { + ps.disconnect(); + vi.useRealTimers(); +}); + +async function connected(map = '{}') { + attribution = map; + const initial = await ps.connect('Rich', `${FN}/`); + sockets[0].open(); + return initial; +} + +const queued = (over: Partial = {}): AttributionEvent => ({ + type: 'queued', + eventType: 'track', + user: 'Bob', + uri: 'u1', + trackName: 'T', + artist: 'A', + timestamp: 5, + ...over, +}); +const groupMsg = (data: unknown) => ({ type: 'message', from: 'group', group: 'office', data }); + +describe('OfficePubSub connect', () => { + it('negotiates, loads attribution, opens the socket with the subprotocol and joins the hub', async () => { + const initial = await connected(JSON.stringify({ u0: { user: 'X', timestamp: 1, trackName: 't', artist: 'a' } })); + expect(fetchMock.mock.calls[0]).toEqual([`${FN}/api/connect?username=Rich`, { method: 'POST' }]); + expect(Object.keys(initial)).toEqual(['u0']); + expect(ps.getMap()).toEqual(initial); + expect(sockets[0].url).toBe('wss://wps.test/0'); + expect(sockets[0].protocol).toBe('json.webpubsub.azure.v1'); + expect(ps.isConnected()).toBe(true); + expect(sockets[0].sent).toEqual([{ type: 'joinGroup', group: 'office' }]); + }); + + it('treats a 404 on attribution as empty and keeps the map', async () => { + const initial = await connected(''); + expect(initial).toEqual({}); + expect(ps.getMap()).toEqual({}); + }); + + it('rejects when negotiation fails (>= 400)', async () => { + negotiateStatus = 500; + await expect(ps.connect('Rich', FN)).rejects.toThrow('500'); + expect(sockets).toHaveLength(0); + }); + + it('refresh reloads the map', async () => { + await connected(); + attribution = JSON.stringify({ z: { user: 'Z', timestamp: 2, trackName: 't', artist: 'a' } }); + expect(Object.keys(await ps.refresh())).toEqual(['z']); + }); +}); + +describe('incoming events', () => { + it('track (and legacy no-eventType) events update the map and notify', async () => { + await connected(); + const cb = vi.fn(); + ps.onEvent(cb); + sockets[0].push(groupMsg(queued())); + sockets[0].push(groupMsg(queued({ eventType: undefined, uri: 'legacy', user: 'Old' }))); + expect(ps.getMap().u1).toEqual({ user: 'Bob', timestamp: 5, trackName: 'T', artist: 'A' }); + expect(ps.getMap().legacy.user).toBe('Old'); + expect(cb).toHaveBeenCalledTimes(2); + }); + + it('album events notify but do not touch the map', async () => { + await connected(); + const cb = vi.fn(); + ps.onEvent(cb); + sockets[0].push(groupMsg(queued({ eventType: 'album', uri: 'album1' }))); + expect(ps.getMap()).toEqual({}); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('ignores other groups, non-group senders, missing uri and malformed payloads', async () => { + await connected(); + const cb = vi.fn(); + ps.onEvent(cb); + sockets[0].push({ ...groupMsg(queued()), group: 'other' }); + sockets[0].push({ ...groupMsg(queued()), from: 'server' }); + sockets[0].push(groupMsg(queued({ uri: '' }))); + sockets[0].push({ type: 'ack' }); + sockets[0].onmessage?.({ data: 'not json' }); + expect(cb).not.toHaveBeenCalled(); + expect(ps.getMap()).toEqual({}); + }); +}); + +describe('publishQueued', () => { + it('ignores items without a uri', async () => { + await connected(); + await ps.publishQueued({ eventType: 'track', uri: '', trackName: 't', artist: 'a' }); + expect(sockets[0].sent).toHaveLength(1); // just joinGroup + expect(fetchMock.mock.calls.some((c) => String(c[0]).includes('log-event'))).toBe(false); + }); + + it('updates the map for tracks, broadcasts with noEcho and logs to Cosmos', async () => { + await connected(); + await ps.publishQueued({ eventType: 'track', uri: 'u9', trackName: 'T', artist: 'A', album: 'Al' }); + expect(ps.getMap().u9).toEqual({ user: 'Rich', timestamp: 1_000_000, trackName: 'T', artist: 'A' }); + const frame = sockets[0].sent[1] as { type: string; group: string; dataType: string; noEcho: boolean; data: AttributionEvent }; + expect(frame).toMatchObject({ type: 'sendToGroup', group: 'office', dataType: 'json', noEcho: true }); + expect(frame.data).toMatchObject({ user: 'Rich', uri: 'u9', eventType: 'track', album: 'Al' }); + const logCall = fetchMock.mock.calls.find((c) => String(c[0]).endsWith('/api/log-event'))!; + expect(logCall[1]).toMatchObject({ method: 'POST', headers: { 'Content-Type': 'application/json' } }); + expect(JSON.parse((logCall[1] as RequestInit).body as string).uri).toBe('u9'); + }); + + it('does not add a badge for album events', async () => { + await connected(); + await ps.publishQueued({ eventType: 'album', uri: 'alb', trackName: 'T', artist: 'A' }); + expect(ps.getMap()).toEqual({}); + expect(sockets[0].sent).toHaveLength(2); + }); + + it('does not send over a socket that is not open', async () => { + await connected(); + sockets[0].readyState = 3; + await ps.publishQueued({ eventType: 'track', uri: 'u', trackName: 'T', artist: 'A' }); + expect(sockets[0].sent).toHaveLength(1); + }); + + it('retries the Cosmos log 3 times with 1s then 2s delays, then warns', async () => { + await connected(); + logStatuses = [500, 500, 500]; + const logCalls = () => fetchMock.mock.calls.filter((c) => String(c[0]).endsWith('/api/log-event')).length; + await ps.publishQueued({ eventType: 'track', uri: 'u', trackName: 'T', artist: 'A' }); + await vi.advanceTimersByTimeAsync(0); + expect(logCalls()).toBe(1); + await vi.advanceTimersByTimeAsync(999); + expect(logCalls()).toBe(1); + await vi.advanceTimersByTimeAsync(1); + expect(logCalls()).toBe(2); + await vi.advanceTimersByTimeAsync(1999); + expect(logCalls()).toBe(2); + await vi.advanceTimersByTimeAsync(1); + expect(logCalls()).toBe(3); + await vi.advanceTimersByTimeAsync(10_000); + expect(logCalls()).toBe(3); + expect(log).toHaveBeenCalledWith('[pubsub] Cosmos log failed after retries:', expect.stringContaining('500')); + }); + + it('stops retrying after a success', async () => { + await connected(); + logStatuses = [500, 200]; + await ps.publishQueued({ eventType: 'track', uri: 'u', trackName: 'T', artist: 'A' }); + await vi.advanceTimersByTimeAsync(20_000); + expect(fetchMock.mock.calls.filter((c) => String(c[0]).endsWith('/api/log-event'))).toHaveLength(2); + expect(log).not.toHaveBeenCalled(); + }); +}); + +describe('reconnect', () => { + const connectCalls = () => fetchMock.mock.calls.filter((c) => String(c[0]).includes('/api/connect')).length; + + it('backs off 1s, 2s, 4s while failing and resets after open', async () => { + await connected(); + negotiateStatus = 500; + sockets[0].drop(); + expect(ps.isConnected()).toBe(false); + + await vi.advanceTimersByTimeAsync(999); + expect(connectCalls()).toBe(1); + await vi.advanceTimersByTimeAsync(1); // 1s: attempt fails + expect(connectCalls()).toBe(2); + await vi.advanceTimersByTimeAsync(1999); + expect(connectCalls()).toBe(2); + await vi.advanceTimersByTimeAsync(1); // +2s + expect(connectCalls()).toBe(3); + await vi.advanceTimersByTimeAsync(3999); + expect(connectCalls()).toBe(3); + negotiateStatus = 200; + await vi.advanceTimersByTimeAsync(1); // +4s: succeeds, new socket + expect(connectCalls()).toBe(4); + expect(sockets).toHaveLength(2); + sockets[1].open(); + expect(ps.isConnected()).toBe(true); + + // backoff reset: next drop waits 1s again + sockets[1].drop(); + await vi.advanceTimersByTimeAsync(1000); + expect(connectCalls()).toBe(5); + }); + + it('caps the delay at 60s', async () => { + await connected(); + negotiateStatus = 500; + sockets[0].drop(); + // delays: 1,2,4,8,16,32,60,60 ... + let total = 0; + for (const d of [1, 2, 4, 8, 16, 32]) total += d * 1000; + await vi.advanceTimersByTimeAsync(total); + const before = connectCalls(); + await vi.advanceTimersByTimeAsync(59_999); + expect(connectCalls()).toBe(before); + await vi.advanceTimersByTimeAsync(1); + expect(connectCalls()).toBe(before + 1); + await vi.advanceTimersByTimeAsync(60_000); + expect(connectCalls()).toBe(before + 2); + }); + + it('does not reconnect after disconnect(), and disconnect cancels a pending reconnect', async () => { + await connected(); + sockets[0].drop(); + ps.disconnect(); + await vi.advanceTimersByTimeAsync(120_000); + expect(connectCalls()).toBe(1); + expect(ps.isConnected()).toBe(false); + + const cb = vi.fn(); + await ps.connect('Rich', FN); + ps.onEvent(cb); + ps.disconnect(); // clears listeners + expect(sockets[1].closed).toBe(true); + await vi.advanceTimersByTimeAsync(120_000); + expect(sockets).toHaveLength(2); + }); + + it('logs socket errors', async () => { + await connected(); + sockets[0].onerror?.({ message: 'kaput' }); + expect(log).toHaveBeenCalledWith('[pubsub] WS error:', 'kaput'); + }); +}); diff --git a/apps/mobile/src/office/__tests__/telemetry.test.ts b/apps/mobile/src/office/__tests__/telemetry.test.ts new file mode 100644 index 0000000..68a6eb4 --- /dev/null +++ b/apps/mobile/src/office/__tests__/telemetry.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as telemetry from '../telemetry'; + +const CS = 'InstrumentationKey=abc-123;IngestionEndpoint=https://uks.in.applicationinsights.azure.com/;LiveEndpoint=https://x'; + +let fetchMock: ReturnType; +const opts = () => ({ fetchImpl: fetchMock as unknown as typeof fetch }); +const sentBatches = () => fetchMock.mock.calls.map((c) => JSON.parse((c[1] as RequestInit).body as string) as Array>); + +beforeEach(() => { + vi.useFakeTimers(); + telemetry._resetForTests(); + fetchMock = vi.fn(async () => new Response('{}', { status: 200 })); +}); + +afterEach(() => { + telemetry._resetForTests(); + vi.useRealTimers(); +}); + +describe('telemetry', () => { + it('is a complete no-op without a connection string', async () => { + telemetry.init('', '1.0.0', opts()); + telemetry.event('e'); + telemetry.exception(new Error('x')); + await telemetry.flush(); + await vi.advanceTimersByTimeAsync(10_000); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('is a no-op for a connection string without an instrumentation key', async () => { + telemetry.init('IngestionEndpoint=https://x', '1.0.0', opts()); + telemetry.event('e'); + await telemetry.flush(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('posts an event envelope to the ingestion endpoint on the 5s timer', async () => { + telemetry.init(CS, '1.2.3', opts()); + telemetry.setContext({ userId: 'Rich', groupId: undefined }); + telemetry.event('track_played', { n: 3, ok: true }); + await vi.advanceTimersByTimeAsync(4999); + expect(fetchMock).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://uks.in.applicationinsights.azure.com/v2/track'); + expect(init.method).toBe('POST'); + const [env] = sentBatches()[0]; + expect(env.name).toBe('Microsoft.ApplicationInsights.Event'); + expect(env.iKey).toBe('abc-123'); + expect(env.data.baseType).toBe('EventData'); + expect(env.data.baseData.name).toBe('track_played'); + expect(env.data.baseData.properties).toMatchObject({ + appVersion: '1.2.3', + platform: 'android', + userId: 'Rich', + n: '3', + ok: 'true', + }); + expect(env.data.baseData.properties.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(env.data.baseData.properties).not.toHaveProperty('groupId'); + }); + + it('honours a platform override and wraps non-Error exceptions', async () => { + telemetry.init(CS, '1', { ...opts(), platform: 'test' }); + telemetry.exception('plain string', { phase: 'boot' }); + telemetry.exception(new TypeError('bad')); + await telemetry.flush(); + const [a, b] = sentBatches()[0]; + expect(a.data.baseType).toBe('ExceptionData'); + expect(a.name).toBe('Microsoft.ApplicationInsights.Exception'); + expect(a.data.baseData.exceptions[0]).toMatchObject({ typeName: 'Error', message: 'plain string' }); + expect(a.data.baseData.properties).toMatchObject({ platform: 'test', phase: 'boot' }); + expect(b.data.baseData.exceptions[0]).toMatchObject({ typeName: 'TypeError', message: 'bad' }); + }); + + it('flushes immediately at 20 queued items', async () => { + telemetry.init(CS, '1', opts()); + for (let i = 0; i < 19; i++) telemetry.event(`e${i}`); + expect(fetchMock).not.toHaveBeenCalled(); + telemetry.event('e19'); + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(sentBatches()[0]).toHaveLength(20); + // timer was cancelled: no empty/duplicate send later + await vi.advanceTimersByTimeAsync(10_000); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('flush() sends pending items and resolves; nothing pending means no request', async () => { + telemetry.init(CS, '1', opts()); + await telemetry.flush(); + expect(fetchMock).not.toHaveBeenCalled(); + telemetry.event('e'); + await telemetry.flush(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('never throws when fetch rejects, and drops the batch', async () => { + fetchMock.mockRejectedValue(new Error('offline')); + telemetry.init(CS, '1', opts()); + telemetry.event('e'); + await expect(telemetry.flush()).resolves.toBeUndefined(); + await vi.advanceTimersByTimeAsync(10_000); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('flush resolves after 3s if the request hangs', async () => { + fetchMock.mockImplementation(() => new Promise(() => {})); + telemetry.init(CS, '1', opts()); + telemetry.event('e'); + let done = false; + void telemetry.flush().then(() => { + done = true; + }); + await vi.advanceTimersByTimeAsync(2999); + expect(done).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(done).toBe(true); + }); +}); diff --git a/apps/mobile/src/office/azureApi.ts b/apps/mobile/src/office/azureApi.ts new file mode 100644 index 0000000..1bf1773 --- /dev/null +++ b/apps/mobile/src/office/azureApi.ts @@ -0,0 +1,322 @@ +// Ported from TrueTunes/src/main.ts (Azure-Function-backed IPC handlers). +// Return shapes, status mapping and error handling mirror desktop exactly. + +export interface AzureApiDeps { + baseUrl: string; + /** Mutable, shared with the app config; read at call time. */ + config: { displayName?: string; entraOid?: string }; + fetchImpl?: typeof fetch; + /** Desktop: telemetry.setContext + saveConfig + reconnect pubsub. */ + onDisplayNameChanged?: (name: string) => void | Promise; +} + +/** URLSearchParams is incomplete in RN, so build query strings by hand (order preserved). */ +function qs(entries: Array<[string, string]>): string { + return entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&'); +} + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +export interface DjFetchOptions { + users?: string[]; + limit?: number; + excludeUris?: string[]; +} + +export interface GameSubmitInput { + gameId: string; + userName: string; + guesses: { main: Array<'left' | 'right'>; bonus: string[] }; +} + +export function createAzureApi(deps: AzureApiDeps) { + const { config } = deps; + const base = deps.baseUrl; + const doFetch: typeof fetch = deps.fetchImpl ?? ((...a) => fetch(...a)); + + /** GET + parse JSON; any failure becomes { error }. */ + async function getJson(url: string): Promise { + try { + const res = await doFetch(url); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } + } + + /** Throws on non-2xx (message from body.error) — used by the playlist mutation handlers. */ + async function playlistFetch(url: string, init: RequestInit): Promise { + const res = await doFetch(url, init); + const body = (await res.json().catch(() => ({}))) as { error?: string }; + if (!res.ok) throw new Error(body.error ?? `Server error ${res.status}`); + return body; + } + + const playlistUrl = (id: string) => `${base}/api/playlist/${encodeURIComponent(id)}`; + const gameDate = (date?: string) => (date && date.length ? date : 'today'); + + /** Returns true if the name is already owned by a different Entra OID. */ + async function claimDisplayName(oid: string, displayName: string): Promise { + try { + const res = await doFetch(`${base}/api/profile/claim`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ oid, displayName }), + }); + return res.status === 409; + } catch { + return false; // network failure → allow gracefully + } + } + + /** Returns the display name linked to this OID in Cosmos, or null if none. */ + async function lookupProfileByOid(oid: string): Promise { + try { + const res = await doFetch(`${base}/api/profile/lookup?oid=${encodeURIComponent(oid)}`); + if (res.status === 404) return null; + if (!res.ok) return null; + const data = (await res.json()) as { displayName?: string }; + return data.displayName ?? null; + } catch { + return null; + } + } + + /** Port of config:setDisplayName. */ + async function setDisplayName(name: string): Promise<{ error: 'taken' } | null> { + // Guard empty name (sign-out path) — skip claim check. + if (name && config.entraOid) { + const taken = await claimDisplayName(config.entraOid, name); + if (taken) return { error: 'taken' }; + } + config.displayName = name; + await deps.onDisplayNameChanged?.(name); + return null; + } + + return { + claimDisplayName, + lookupProfileByOid, + setDisplayName, + + fetchPlaylists(filter: { owner?: string; member?: string }): Promise { + const entries: Array<[string, string]> = []; + if (filter.owner) entries.push(['owner', filter.owner]); + if (filter.member) entries.push(['member', filter.member]); + return getJson(`${base}/api/playlists?${qs(entries)}`); + }, + + fetchPlaylist(id: string): Promise { + return getJson(playlistUrl(id)); + }, + + createPlaylist(name: string, isPublic: boolean): Promise { + return playlistFetch(`${base}/api/playlists`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ name, isPublic, owner: config.displayName }), + }); + }, + + updatePlaylist(playlistId: string, patch: { name?: string; isPublic?: boolean }): Promise { + return playlistFetch(playlistUrl(playlistId), { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ userName: config.displayName, ...patch }), + }); + }, + + deletePlaylist(playlistId: string): Promise { + return playlistFetch(playlistUrl(playlistId), { + method: 'DELETE', + headers: JSON_HEADERS, + body: JSON.stringify({ userName: config.displayName ?? '' }), + }); + }, + + addTrackToPlaylist(playlistId: string, track: unknown): Promise { + return playlistFetch(`${playlistUrl(playlistId)}/tracks`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ track, userName: config.displayName }), + }); + }, + + removeTrackFromPlaylist(playlistId: string, uri: string): Promise { + return playlistFetch(`${playlistUrl(playlistId)}/tracks`, { + method: 'DELETE', + headers: JSON_HEADERS, + body: JSON.stringify({ userName: config.displayName, uri }), + }); + }, + + reorderPlaylistTracks(playlistId: string, fromIndex: number, toIndex: number): Promise { + return playlistFetch(`${playlistUrl(playlistId)}/tracks`, { + method: 'PATCH', + headers: JSON_HEADERS, + body: JSON.stringify({ userName: config.displayName, fromIndex, toIndex }), + }); + }, + + joinPlaylist(playlistId: string, action: 'join' | 'leave'): Promise { + return playlistFetch(`${playlistUrl(playlistId)}/members`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ userName: config.displayName, action }), + }); + }, + + async uploadPlaylistImage( + playlistId: string, + data: ArrayBuffer, + mimeType: string, + userName: string, + ): Promise { + try { + const res = await doFetch(`${playlistUrl(playlistId)}/image?userName=${encodeURIComponent(userName)}`, { + method: 'POST', + headers: { 'Content-Type': mimeType }, + body: data, + }); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } + }, + + async ensureFavourites(): Promise { + const userName = config.displayName; + if (!userName) throw new Error('No display name set'); + return playlistFetch(`${base}/api/profile/${encodeURIComponent(userName)}/favourites`, { + method: 'POST', + }); + }, + + async fetchUsers(includeSelf?: boolean): Promise { + // Default excludes yourself; the room picker wants everyone. + const exclude = includeSelf ? '' : (config.displayName ?? ''); + const params = exclude ? `?exclude=${encodeURIComponent(exclude)}` : ''; + try { + const res = await doFetch(`${base}/api/users${params}`); + const body = (await res.json()) as unknown; + return Array.isArray(body) ? body : []; + } catch { + return []; + } + }, + + fetchUserProfile(userName: string): Promise { + return getJson(`${base}/api/profile/${encodeURIComponent(userName)}`); + }, + + async uploadProfileImage(userName: string, data: ArrayBuffer, mimeType: string): Promise { + try { + const res = await doFetch(`${base}/api/profile/${encodeURIComponent(userName)}/image`, { + method: 'POST', + headers: { 'Content-Type': mimeType }, + body: data, + }); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } + }, + + async renameUser(oldName: string, newName: string): Promise<{ ok?: boolean; error?: string }> { + if (!config.entraOid) return { error: 'not-authed' }; + try { + const res = await doFetch(`${base}/api/profile/rename`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ oid: config.entraOid, oldName, newName }), + }); + if (res.status === 409) return { error: 'taken' }; + if (res.status === 403) return { error: 'not-owner' }; + if (!res.ok) return { error: `server error ${res.status}` }; + config.displayName = newName; + await deps.onDisplayNameChanged?.(newName); + return { ok: true }; + } catch (err) { + return { error: String(err) }; + } + }, + + fetchStats(period: string, userId?: string, count?: number): Promise { + let url = `${base}/api/stats?period=${encodeURIComponent(period)}`; + if (userId) url += `&userId=${encodeURIComponent(userId)}`; + if (count) url += `&count=${count}`; + return getJson(url); + }, + + async djAutoplay(body: unknown): Promise { + try { + const res = await doFetch(`${base}/api/dj/autoplay`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify(body ?? {}), + }); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } + }, + + fetchDjSet(opts: DjFetchOptions = {}): Promise { + const entries: Array<[string, string]> = []; + if (opts.users?.length) entries.push(['users', opts.users.join(',')]); + if (opts.limit) entries.push(['limit', String(opts.limit)]); + if (opts.excludeUris?.length) entries.push(['exclude', opts.excludeUris.join(',')]); + return getJson(`${base}/api/dj?${qs(entries)}`); + }, + + fetchRecentlyPlayed(userId: string): Promise { + return getJson(`${base}/api/recently-played?userId=${encodeURIComponent(userId)}`); + }, + + async fetchDailyGame(date?: string): Promise { + try { + const res = await doFetch(`${base}/api/game?date=${encodeURIComponent(gameDate(date))}`); + if (res.status === 404 || res.status === 202) return { status: 'pending' }; + return await res.json(); + } catch (err) { + return { error: String(err) }; + } + }, + + async submitGameScore(input: GameSubmitInput): Promise { + try { + const res = await doFetch(`${base}/api/score`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify(input), + }); + const body = await res.json(); + if (res.status === 409) return { duplicate: true, existing: (body as { existing?: unknown }).existing }; + if (!res.ok) return { error: (body as { error?: string }).error ?? `HTTP ${res.status}` }; + return { ok: true, score: body }; + } catch (err) { + return { error: String(err) }; + } + }, + + fetchGameLeaderboard(date?: string): Promise { + return getJson(`${base}/api/game-leaderboard?date=${encodeURIComponent(gameDate(date))}`); + }, + + fetchGameDates(userName: string): Promise { + return getJson(`${base}/api/game-dates?userName=${encodeURIComponent(userName ?? '')}`); + }, + + fetchMyScore(gameId: string, userName: string): Promise { + return getJson( + `${base}/api/my-score?gameId=${encodeURIComponent(gameId)}&userName=${encodeURIComponent(userName)}`, + ); + }, + + fetchGameStats(date?: string): Promise { + return getJson(`${base}/api/game-stats?date=${encodeURIComponent(gameDate(date))}`); + }, + }; +} + +export type AzureApi = ReturnType; diff --git a/apps/mobile/src/office/config.ts b/apps/mobile/src/office/config.ts new file mode 100644 index 0000000..fb1e08b --- /dev/null +++ b/apps/mobile/src/office/config.ts @@ -0,0 +1,66 @@ +// Ported from TrueTunes/src/main.ts (loadConfig / saveConfig). +// The file on disk becomes a single JSON string in an injected key-value store. + +import type { SonosConfig } from '../sonos/types'; + +export interface AppConfig extends SonosConfig { + preferredCoordinatorId?: string; + displayName?: string; + entraOid?: string; + entraName?: string; + entraEmail?: string; + lastSeenVersion?: string; + queueDockedWidth?: number; +} + +export interface KeyValueStore { + get(key: string): Promise; + set(key: string, value: string): Promise; + remove(key: string): Promise; +} + +const CONFIG_KEY = 'config'; + +const STRING_FIELDS = [ + 'displayName', + 'entraOid', + 'entraName', + 'entraEmail', + 'lastSeenVersion', + 'preferredCoordinatorId', +] as const; + +/** Only persisted fields are loaded (not runtime-discovered ones); each is type-checked. */ +export async function loadConfig(store: KeyValueStore, config: AppConfig): Promise { + try { + const raw = await store.get(CONFIG_KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as Partial | null; + if (!parsed || typeof parsed !== 'object') return; + for (const key of STRING_FIELDS) { + const value = parsed[key]; + if (typeof value === 'string') config[key] = value; + } + if (typeof parsed.queueDockedWidth === 'number') config.queueDockedWidth = parsed.queueDockedWidth; + } catch { + // No config yet or unreadable — use defaults + } +} + +export async function saveConfig(store: KeyValueStore, config: AppConfig): Promise { + try { + // Only persist non-sensitive, user-set fields + const toSave: Partial = { + displayName: config.displayName, + entraOid: config.entraOid, + entraName: config.entraName, + entraEmail: config.entraEmail, + lastSeenVersion: config.lastSeenVersion, + preferredCoordinatorId: config.preferredCoordinatorId, + queueDockedWidth: config.queueDockedWidth, + }; + await store.set(CONFIG_KEY, JSON.stringify(toSave, null, 2)); + } catch (err) { + console.warn('[config] Failed to save:', err); + } +} diff --git a/apps/mobile/src/office/genius.ts b/apps/mobile/src/office/genius.ts new file mode 100644 index 0000000..e649548 --- /dev/null +++ b/apps/mobile/src/office/genius.ts @@ -0,0 +1,125 @@ +// Ported from TrueTunes/src/main.ts (genius:description / genius:artist / genius:albumYear). +// Debug-window plumbing dropped. Every failure or a missing key resolves to null. + +export interface GeniusDeps { + getKey: () => string | undefined; + fetchImpl?: typeof fetch; + log?: (...a: unknown[]) => void; +} + +const API = 'https://api.genius.com'; + +export function createGenius(deps: GeniusDeps) { + const doFetch: typeof fetch = deps.fetchImpl ?? ((...a) => fetch(...a)); + const log = deps.log ?? (() => {}); + + /** Returns the response body text, or null on non-2xx. */ + async function get(path: string, key: string): Promise { + const res = await doFetch(`${API}${path}`, { headers: { Authorization: `Bearer ${key}` } }); + const body = await res.text(); + return res.ok ? body : null; + } + + async function description(trackName: string, artistName: string): Promise { + const key = deps.getKey(); + if (!key) { + log('[genius] GENIUS_ACCESS_TOKEN not set — skipping'); + return null; + } + try { + const searchBody = await get(`/search?q=${encodeURIComponent(`${trackName} ${artistName}`)}`, key); + if (searchBody === null) return null; + const searchData = JSON.parse(searchBody) as { response: { hits: { result: { id: number } }[] } }; + const songId = searchData.response?.hits?.[0]?.result?.id; + if (!songId) return null; + + const songBody = await get(`/songs/${songId}`, key); + if (songBody === null) return null; + const songData = JSON.parse(songBody) as { response: { song: { description: { dom: unknown } } } }; + return songData.response?.song?.description?.dom ?? null; + } catch (err) { + log(`[genius] error: ${err}`); + return null; + } + } + + async function artist(artistName: string, trackHint?: string): Promise { + const key = deps.getKey(); + if (!key) { + log('[genius] GENIUS_ACCESS_TOKEN not set — skipping artist lookup'); + return null; + } + try { + const q = trackHint ? `${trackHint} ${artistName}` : artistName; + const searchBody = await get(`/search?q=${encodeURIComponent(q)}`, key); + if (searchBody === null) return null; + + type SearchHit = { result: { primary_artist: { id: number; name: string } } }; + const searchData = JSON.parse(searchBody) as { response: { hits: SearchHit[] } }; + const hits = searchData.response?.hits ?? []; + const artistNameLower = artistName.toLowerCase(); + const hit = hits.find((h) => h.result.primary_artist.name.toLowerCase().includes(artistNameLower)) ?? hits[0]; + const artistId = hit?.result?.primary_artist?.id; + if (!artistId) return null; + + const artistBody = await get(`/artists/${artistId}`, key); + if (artistBody === null) return null; + + type ArtistPayload = { + response: { + artist: { + description: { dom: unknown }; + alternate_names: string[]; + image_url: string | null; + header_image_url: string | null; + instagram_name: string | null; + twitter_name: string | null; + }; + }; + }; + const a = (JSON.parse(artistBody) as ArtistPayload).response?.artist; + if (!a) return null; + + return { + description: a.description?.dom ?? null, + alternateNames: a.alternate_names ?? [], + imageUrl: a.image_url ?? null, + headerImageUrl: a.header_image_url ?? null, + instagram: a.instagram_name ?? null, + twitter: a.twitter_name ?? null, + }; + } catch (err) { + log(`[genius] artist error: ${err}`); + return null; + } + } + + async function albumYear(albumName: string, artistName: string): Promise { + const key = deps.getKey(); + if (!key) { + log('[genius] GENIUS_ACCESS_TOKEN not set — skipping albumYear'); + return null; + } + try { + const searchBody = await get(`/search?q=${encodeURIComponent(`${albumName} ${artistName}`)}`, key); + if (searchBody === null) return null; + + type Hit = { + result: { + primary_artist: { name: string } | null; + release_date_components: { year: number | null } | null; + }; + }; + const searchData = JSON.parse(searchBody) as { response: { hits: Hit[] } }; + const hits = searchData.response?.hits ?? []; + const lower = artistName.toLowerCase(); + const hit = hits.find((h) => h.result.primary_artist?.name?.toLowerCase().includes(lower)) ?? hits[0]; + return hit?.result?.release_date_components?.year ?? null; + } catch (err) { + log(`[genius] albumYear error: ${err}`); + return null; + } + } + + return { description, artist, albumYear }; +} diff --git a/apps/mobile/src/office/graphPresence.ts b/apps/mobile/src/office/graphPresence.ts new file mode 100644 index 0000000..2bd34d3 --- /dev/null +++ b/apps/mobile/src/office/graphPresence.ts @@ -0,0 +1,154 @@ +/** + * Who is actually in the office, from Microsoft Graph. + * + * `presence.workLocation.workLocationType` is the signal we want: `office` means + * in the building, not merely online. Teams sets it automatically from network + * and peripheral signals, or the user sets it by hand in Teams/Outlook. + * + * Availability alone is not a substitute — someone working from home shows as + * `available` and cannot hear the speaker — so it's only used as a fallback when + * nobody in the org populates work location. + */ + +const GRAPH_PRESENCE_URL = 'https://graph.microsoft.com/v1.0/communications/getPresencesByUserId'; + +/** Graph caps a single request at 650 ids. */ +const MAX_IDS_PER_REQUEST = 650; + +export interface GraphPresence { + id: string; + availability?: string; + activity?: string; + workLocation?: { + workLocationType?: 'unspecified' | 'office' | 'remote' | 'timeOff'; + source?: 'none' | 'manual' | 'scheduled' | 'automatic'; + placeId?: string; + }; +} + +/** Availability values that mean "at a desk", used only for the fallback. */ +const AT_DESK = new Set(['available', 'busy', 'inacall', 'inameeting', 'presenting', 'donotdisturb', 'focusing']); + +/** + * Not signed in to Teams at all. `presenceUnknown` usually means no Teams + * licence rather than absence, but either way we have no evidence they're here. + */ +const NOT_SIGNED_IN = new Set(['offline', 'presenceunknown']); + +export function isAtDesk(presence: GraphPresence): boolean { + return AT_DESK.has(String(presence.availability ?? '').toLowerCase()); +} + +/** + * In the office AND actually signed in. + * + * Work location alone isn't enough: a `scheduled` source is only what someone's + * Outlook working pattern claims for today, and nobody updates the plan when + * they change it. We saw exactly that — `office` (scheduled) alongside + * `Offline`, i.e. someone rostered in who never turned up. Requiring a live + * Teams session filters those out while leaving the `automatic` signals — which + * Teams derived from being physically on the network — untouched. + */ +export function isInOffice(presence: GraphPresence): boolean { + if (presence.workLocation?.workLocationType !== 'office') return false; + return !NOT_SIGNED_IN.has(String(presence.availability ?? '').toLowerCase()); +} + +export async function fetchPresences( + token: string, + oids: string[], + fetchImpl: typeof fetch = (...a) => fetch(...a), +): Promise { + const out: GraphPresence[] = []; + + for (let i = 0; i < oids.length; i += MAX_IDS_PER_REQUEST) { + const batch = oids.slice(i, i + MAX_IDS_PER_REQUEST); + const res = await fetchImpl(GRAPH_PRESENCE_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: batch }), + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error(`Graph presence ${res.status}: ${detail.slice(0, 200)}`); + } + + const body = (await res.json()) as { value?: GraphPresence[] }; + out.push(...(body.value ?? [])); + } + + return out; +} + +export interface OfficeRoster { + /** Names to build the DJ set for. */ + inOffice: string[]; + /** + * Which signal decided the room, best first. The UI states this rather than + * implying certainty it doesn't have. + * 'sensed' — Teams detected them on the office network. Trusted. + * 'rostered' — their Outlook working pattern claims office today. + * 'availability' — merely online; includes people working from home. + * 'none' — nothing to go on; caller falls back to queue activity. + */ + basis: 'sensed' | 'rostered' | 'availability' | 'none'; + /** How many of `inOffice` Teams detected automatically, rather than were rostered. */ + observed: number; + /** Everyone Graph answered for, for display and debugging. */ + detail: Array<{ userId: string; availability?: string; workLocationType?: string; source?: string }>; +} + +/** + * Resolve a roster from Graph presences, preferring sensed presence over stated + * intent. + * + * Where Teams is detecting people automatically, that detection is the source of + * truth — and its silence is informative too. Someone marked `office` by their + * Outlook schedule, showing Available, but never seen on the office network is + * most likely working elsewhere; the colleagues Teams did detect prove the + * detection is working in that building. + * + * Each rung down is a weaker claim, which is why `basis` is reported rather than + * flattened away: "at a desk" quietly masquerading as "in the office" would + * include everyone working from home. + */ +export function resolveRoster( + presences: GraphPresence[], + oidToName: Record, +): OfficeRoster { + const detail = presences.map((p) => ({ + userId: oidToName[p.id] ?? p.id, + availability: p.availability, + workLocationType: p.workLocation?.workLocationType, + source: p.workLocation?.source, + })); + + // 'automatic' means Teams saw them on the office network — an observation. + // Everything else is someone's stated intent. + const observed = presences.filter( + (p) => isInOffice(p) && p.workLocation?.source === 'automatic', + ).length; + + const names = (list: GraphPresence[]) => list.map((p) => oidToName[p.id]).filter(Boolean); + + // 1. Sensed. Teams saw them on the network — trust it, and trust its silence. + if (observed > 0) { + const sensed = presences.filter( + (p) => isInOffice(p) && p.workLocation?.source === 'automatic', + ); + return { inOffice: names(sensed), basis: 'sensed', observed, detail }; + } + + // 2. Rostered. No automatic detection anywhere, so fall back to stated intent. + const anyWorkLocation = presences.some( + (p) => p.workLocation?.workLocationType && p.workLocation.workLocationType !== 'unspecified', + ); + if (anyWorkLocation) { + return { inOffice: names(presences.filter(isInOffice)), basis: 'rostered', observed, detail }; + } + + // 3. Online somewhere. Weakest signal — says as much. + const atDesk = names(presences.filter(isAtDesk)); + return { inOffice: atDesk, basis: atDesk.length ? 'availability' : 'none', observed: 0, detail }; +} diff --git a/apps/mobile/src/office/officePresence.ts b/apps/mobile/src/office/officePresence.ts new file mode 100644 index 0000000..c3c4ec4 --- /dev/null +++ b/apps/mobile/src/office/officePresence.ts @@ -0,0 +1,41 @@ +// Ported from TrueTunes/src/main.ts ('presence:office' handler). + +import { fetchPresences, resolveRoster } from './graphPresence'; + +export interface OfficePresenceDeps { + baseUrl: string; + /** Resolves a Graph token with the presence scope, or null when there is no consent. */ + getGraphToken: () => Promise | null; + /** Whether Entra auth is initialised (desktop: `entraAuth` non-null). */ + hasAuth: () => boolean; + fetchImpl?: typeof fetch; +} + +export function createOfficePresence(deps: OfficePresenceDeps) { + const doFetch: typeof fetch = deps.fetchImpl ?? ((...a) => fetch(...a)); + + async function fetchOfficePresence(): Promise { + try { + if (!deps.hasAuth()) return { inOffice: [], basis: 'none', detail: [], error: 'not signed in' }; + + const token = await deps.getGraphToken(); + // No consent yet — the caller falls back to inferring the room from queue activity. + if (!token) return { inOffice: [], basis: 'none', detail: [], error: 'no presence consent' }; + + const res = await doFetch(`${deps.baseUrl}/api/users`); + const users = (await res.json()) as Array<{ userId: string; entraOid?: string | null }>; + const oidToName: Record = {}; + for (const u of users) if (u.entraOid) oidToName[u.entraOid] = u.userId; + + const oids = Object.keys(oidToName); + if (oids.length === 0) return { inOffice: [], basis: 'none', detail: [] }; + + const presences = await fetchPresences(token, oids, doFetch); + return resolveRoster(presences, oidToName); + } catch (err) { + return { inOffice: [], basis: 'none', detail: [], error: String(err) }; + } + } + + return { fetchOfficePresence }; +} diff --git a/apps/mobile/src/office/pubsub.ts b/apps/mobile/src/office/pubsub.ts new file mode 100644 index 0000000..dea1a76 --- /dev/null +++ b/apps/mobile/src/office/pubsub.ts @@ -0,0 +1,291 @@ +/** + * OfficePubSub — Azure Web PubSub client for the "who queued this?" attribution feature. + * Ported from TrueTunes/src/pubsub.ts; Node ws/http/https/Buffer replaced by injected + * fetch + a WebSocket-like factory so it runs in Node tests and in React Native. + * + * Cosmos (via /api/attribution) is the source of truth; Web PubSub hub "office" + * broadcasts queue events in real time. Designed to fail silently. + */ + +const HUB = 'office'; +const SUBPROTOCOL = 'json.webpubsub.azure.v1'; +const WS_OPEN = 1; + +export interface AttributionEntry { + user: string; + timestamp: number; + trackName: string; + artist: string; +} + +/** Keyed by track objectId / URI */ +export type AttributionMap = Record; + +export type EventType = 'track' | 'album'; + +export interface AttributionEvent { + type: 'queued'; + /** Distinguishes per-track events from album-rollup events. Absent on legacy events. */ + eventType?: EventType; + user: string; + uri: string; + trackName: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + album?: string; + albumId?: string; + imageUrl?: string; + timestamp: number; +} + +/** Minimal WebSocket surface (RN's global WebSocket satisfies it). readyState 1 = OPEN. */ +export interface PubSubSocket { + readyState: number; + send(data: string): void; + close(): void; + onopen: ((ev?: unknown) => void) | null; + onmessage: ((ev: { data: unknown }) => void) | null; + onerror: ((ev: { message?: string }) => void) | null; + onclose: ((ev?: unknown) => void) | null; +} + +export interface PubSubDeps { + createSocket: (url: string, protocol: string) => PubSubSocket; + fetchImpl?: typeof fetch; + log?: (...a: unknown[]) => void; +} + +export class OfficePubSub { + private ws: PubSubSocket | null = null; + private functionUrl = ''; + private attributionMap: AttributionMap = {}; + private eventCbs: ((e: AttributionEvent) => void)[] = []; + private username = ''; + private connected = false; + private intentionalClose = false; + private reconnectAttempts = 0; + private reconnectTimer: ReturnType | null = null; + + private readonly createSocket: PubSubDeps['createSocket']; + private readonly doFetch: typeof fetch; + private readonly log: (...a: unknown[]) => void; + + constructor(deps: PubSubDeps) { + this.createSocket = deps.createSocket; + this.doFetch = deps.fetchImpl ?? ((...a) => fetch(...a)); + this.log = deps.log ?? ((...a) => console.warn(...a)); + } + + /** Connect to Azure Web PubSub and load attribution history from Cosmos. */ + async connect(username: string, functionUrl: string): Promise { + this.username = username; + this.functionUrl = functionUrl.replace(/\/$/, ''); + this.intentionalClose = false; + + const webPubSubUrl = await this.negotiate(); + const initialMap = await this.loadAttribution(); + + this.openWebSocket(webPubSubUrl); + + return initialMap; + } + + /** Publish a "track queued" event. Updates local map and broadcasts via WS. */ + async publishQueued(item: { eventType: 'track' | 'album'; uri: string; trackName: string; artist: string; serviceId?: string; accountId?: string; artistId?: string; album?: string; albumId?: string; imageUrl?: string }): Promise { + if (!item.uri) return; + + const event: AttributionEvent = { + type: 'queued', + eventType: item.eventType, + user: this.username, + uri: item.uri, + trackName: item.trackName, + artist: item.artist, + serviceId: item.serviceId, + accountId: item.accountId, + artistId: item.artistId, + album: item.album, + albumId: item.albumId, + imageUrl: item.imageUrl, + timestamp: Date.now(), + }; + + // Only track events back queue-row badges; album events publish under the album + // URI (no queue row to match) so skip the map update to avoid a phantom badge. + if (item.eventType === 'track') { + this.attributionMap[item.uri] = { + user: event.user, + timestamp: event.timestamp, + trackName: event.trackName, + artist: event.artist, + }; + } + + // Broadcast via Web PubSub (noEcho: true — we already updated locally) + if (this.connected) { + this.wsSend({ + type: 'sendToGroup', + group: HUB, + data: event, + dataType: 'json', + noEcho: true, + }); + } + + // Log to Cosmos via Azure Function (with retry) — this is the durable record + if (this.functionUrl) { + this.logEventWithRetry(event); + } + } + + onEvent(cb: (e: AttributionEvent) => void): void { + this.eventCbs.push(cb); + } + + getMap(): AttributionMap { + return { ...this.attributionMap }; + } + + /** Re-query Cosmos for the latest attribution map. */ + async refresh(): Promise { + return this.loadAttribution(); + } + + isConnected(): boolean { + return this.connected; + } + + disconnect(): void { + this.intentionalClose = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.ws?.close(); + this.ws = null; + this.connected = false; + this.reconnectAttempts = 0; + this.eventCbs = []; + } + + // ── Private ───────────────────────────────────────────────────────────────── + + /** POST /api/connect → signed Web PubSub client URL. Throws on >= 400. */ + private async negotiate(): Promise { + const url = `${this.functionUrl}/api/connect?username=${encodeURIComponent(this.username)}`; + const res = await this.doFetch(url, { method: 'POST' }); + if (res.status >= 400) throw new Error(`POST ${url} → ${res.status}`); + const { webPubSubUrl } = JSON.parse(await res.text()) as { webPubSubUrl: string; [k: string]: unknown }; + return webPubSubUrl; + } + + private openWebSocket(wsUrl: string): void { + const ws = this.createSocket(wsUrl, SUBPROTOCOL); + this.ws = ws; + + ws.onopen = () => { + this.connected = true; + this.reconnectAttempts = 0; + this.wsSend({ type: 'joinGroup', group: HUB }); + }; + + ws.onmessage = (ev) => { + try { + const msg = JSON.parse(String(ev.data)) as { + type: string; + from?: string; + group?: string; + data?: AttributionEvent; + }; + if (msg.type === 'message' && msg.from === 'group' && msg.group === HUB && msg.data) { + const event = msg.data; + if (event.type === 'queued' && event.uri) { + // Album events would place a phantom badge under the album URI. Legacy + // events with no eventType are treated as track-equivalent. + if (event.eventType !== 'album') { + this.attributionMap[event.uri] = { + user: event.user, + timestamp: event.timestamp, + trackName: event.trackName, + artist: event.artist, + }; + } + this.eventCbs.forEach((cb) => cb(event)); + } + } + } catch { /* ignore malformed messages */ } + }; + + ws.onerror = (err) => { + this.log('[pubsub] WS error:', err?.message); + }; + + ws.onclose = () => { + this.connected = false; + if (!this.intentionalClose) { + this.scheduleReconnect(); + } + }; + } + + private scheduleReconnect(): void { + if (this.reconnectTimer) return; + const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 60_000); + this.reconnectAttempts++; + this.reconnectTimer = setTimeout(async () => { + this.reconnectTimer = null; + if (this.intentionalClose || !this.functionUrl) return; + try { + this.openWebSocket(await this.negotiate()); + } catch { + this.scheduleReconnect(); + } + }, delay); + } + + private logEventWithRetry(event: AttributionEvent): void { + const url = `${this.functionUrl}/api/log-event`; + const attempt = async (n: number): Promise => { + try { + const res = await this.doFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }); + if (res.status >= 400) throw new Error(`POST ${url} → ${res.status}`); + } catch (err) { + if (n < 2) { + setTimeout(() => void attempt(n + 1), 1000 * (n + 1)); + } else { + this.log('[pubsub] Cosmos log failed after retries:', (err as Error).message); + } + } + }; + void attempt(0); + } + + private async loadAttribution(): Promise { + if (!this.functionUrl) return {}; + try { + const url = `${this.functionUrl}/api/attribution`; + const res = await this.doFetch(url); + if (res.status === 404) return {}; + if (res.status >= 400) throw new Error(`GET ${url} → ${res.status}`); + const text = await res.text(); + if (!text.length) return {}; + const data = JSON.parse(text) as AttributionMap; + this.attributionMap = data; + return data; + } catch { + return {}; + } + } + + private wsSend(msg: object): void { + if (this.ws?.readyState === WS_OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } +} diff --git a/apps/mobile/src/office/telemetry.ts b/apps/mobile/src/office/telemetry.ts new file mode 100644 index 0000000..4711586 --- /dev/null +++ b/apps/mobile/src/office/telemetry.ts @@ -0,0 +1,171 @@ +/** + * Dependency-free Application Insights client for React Native. + * Same public API as the desktop wrapper (TrueTunes/src/telemetry.ts), but posts + * envelopes to `${IngestionEndpoint}/v2/track` directly since the Node SDK can't run in RN. + * Complete no-op when the connection string is empty; nothing here ever throws. + */ + +type StringProps = Record; +type AnyProps = Record; + +export interface TelemetryInitOptions { + fetchImpl?: typeof fetch; + platform?: string; +} + +const FLUSH_AT = 20; +const FLUSH_INTERVAL_MS = 5000; +const DEFAULT_ENDPOINT = 'https://dc.services.visualstudio.com'; + +/** v4-ish id without crypto (Math.random is fine for a session correlation id). */ +function makeSessionId(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.floor(Math.random() * 16); + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +const SESSION_ID = makeSessionId(); + +interface Envelope { + name: string; + time: string; + iKey: string; + tags: Record; + data: { baseType: string; baseData: Record }; +} + +let _key = ''; +let _endpoint = ''; +let _fetch: typeof fetch | null = null; +let _common: StringProps = {}; +let _ctx: StringProps = {}; +let _queue: Envelope[] = []; +let _timer: ReturnType | null = null; + +function parseConnectionString(cs: string): { key: string; endpoint: string } | null { + const parts: Record = {}; + for (const seg of cs.split(';')) { + const i = seg.indexOf('='); + if (i > 0) parts[seg.slice(0, i).trim().toLowerCase()] = seg.slice(i + 1).trim(); + } + const key = parts['instrumentationkey']; + if (!key) return null; + const endpoint = (parts['ingestionendpoint'] || DEFAULT_ENDPOINT).replace(/\/+$/, ''); + return { key, endpoint }; +} + +/** Call once at app startup. Silently no-ops if connectionString is empty. */ +export function init(connectionString: string, appVersion: string, opts: TelemetryInitOptions = {}): void { + if (!connectionString) return; + try { + const parsed = parseConnectionString(connectionString); + if (!parsed) return; + _key = parsed.key; + _endpoint = parsed.endpoint; + _fetch = opts.fetchImpl ?? ((...a) => fetch(...a)); + _common = { appVersion, sessionId: SESSION_ID, platform: opts.platform ?? 'android' }; + _queue = []; + } catch (err) { + console.warn('[telemetry] Failed to initialize:', err); + } +} + +/** Merge additional key/value pairs into every subsequent event. */ +export function setContext(ctx: Partial): void { + const defined = Object.fromEntries(Object.entries(ctx).filter(([, v]) => v !== undefined)) as StringProps; + _ctx = { ..._ctx, ...defined }; +} + +function enqueue(name: string, baseType: string, baseData: Record): void { + _queue.push({ + name: `Microsoft.ApplicationInsights.${name}`, + time: new Date().toISOString(), + iKey: _key, + tags: { 'ai.session.id': SESSION_ID, 'ai.cloud.roleInstance': _common.platform }, + data: { baseType, baseData }, + }); + if (_queue.length >= FLUSH_AT) { + void send(); + } else if (!_timer) { + _timer = setTimeout(() => { + _timer = null; + void send(); + }, FLUSH_INTERVAL_MS); + } +} + +/** Track a named event with optional properties. */ +export function event(name: string, props?: AnyProps): void { + if (!_fetch) return; + try { + const strProps: StringProps = {}; + for (const [k, v] of Object.entries(props ?? {})) strProps[k] = String(v); + enqueue('Event', 'EventData', { + ver: 2, + name, + properties: { ..._common, ..._ctx, ...strProps }, + }); + } catch { /* ignore — telemetry must never crash the app */ } +} + +/** Track an exception. Pass any value; non-Error values are wrapped automatically. */ +export function exception(err: unknown, props?: StringProps): void { + if (!_fetch) return; + try { + const error = err instanceof Error ? err : new Error(String(err)); + enqueue('Exception', 'ExceptionData', { + ver: 2, + exceptions: [ + { + typeName: error.name, + message: error.message, + hasFullStack: !!error.stack, + stack: error.stack, + }, + ], + properties: { ..._common, ..._ctx, ...props }, + }); + } catch { /* ignore */ } +} + +async function send(): Promise { + if (_timer) { + clearTimeout(_timer); + _timer = null; + } + if (!_fetch || _queue.length === 0) return; + const batch = _queue; + _queue = []; + try { + await _fetch(`${_endpoint}/v2/track`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(batch), + }); + } catch { /* drop the batch — telemetry is best-effort */ } +} + +/** Flush buffered telemetry. Resolves after the send completes or after 3s, whichever is first. */ +export function flush(): Promise { + return new Promise((resolve) => { + if (!_fetch) { resolve(); return; } + const timer = setTimeout(resolve, 3000); + send().then( + () => { clearTimeout(timer); resolve(); }, + () => { clearTimeout(timer); resolve(); }, + ); + }); +} + +/** Test helper: clear all module state. */ +export function _resetForTests(): void { + if (_timer) clearTimeout(_timer); + _timer = null; + _key = ''; + _endpoint = ''; + _fetch = null; + _common = {}; + _ctx = {}; + _queue = []; +} diff --git a/apps/mobile/src/platform/AppGate.tsx b/apps/mobile/src/platform/AppGate.tsx new file mode 100644 index 0000000..3d6bc77 --- /dev/null +++ b/apps/mobile/src/platform/AppGate.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; +import { ActivityIndicator, AppState, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { RemoteProvider } from '../logic/RemoteContext'; +import { colors, space, type } from '../ui/theme'; +import { core, startCore } from './core'; +import { SonosLogin } from './SonosLogin'; + +type Phase = 'booting' | 'login' | 'ready'; + +/** + * Boots the core and shows the right thing: spinner while restoring the session, the Sonos + * sign-in WebView when there is no valid session, otherwise the app. A 401 anywhere drops back + * to sign-in via auth:expired. + */ +export function AppGate({ children }: { children: ReactNode }) { + const [phase, setPhase] = useState('booting'); + const insets = useSafeAreaInsets(); + + useEffect(() => { + const offReady = core.bridge.onAuthReady(() => setPhase('ready')); + const offExpired = core.bridge.onAuthExpired(() => setPhase('login')); + startCore() + .then((r) => { + if (r === 'needs-login') setPhase('login'); + }) + .catch(() => setPhase('login')); + // Android kills background sockets, so reconnect when the app returns. + const sub = AppState.addEventListener('change', (s) => { + if (s === 'active') core.onForeground(); + }); + return () => { + offReady(); + offExpired(); + sub.remove(); + }; + }, []); + + if (phase === 'booting') { + return ( + + + + ); + } + + if (phase === 'login') { + return ( + + + Sign in to Sonos + Use your normal Sonos account. True Tunes never sees your password. + + void core.submitSonosToken(token)} /> + + ); + } + + return {children}; +} + +const styles = StyleSheet.create({ + center: { flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' }, + login: { flex: 1, backgroundColor: colors.bg }, + loginHeader: { padding: space.lg, gap: space.xs }, +}); diff --git a/apps/mobile/src/platform/SonosLogin.tsx b/apps/mobile/src/platform/SonosLogin.tsx new file mode 100644 index 0000000..6c6af51 --- /dev/null +++ b/apps/mobile/src/platform/SonosLogin.tsx @@ -0,0 +1,39 @@ +import { useCallback, useRef } from 'react'; +import { WebView } from 'react-native-webview'; +import { BASE_URL, SPOOF_UA } from '../sonos/constants'; +import { looksSignedIn } from '../core/loginUrl'; +import { readSonosSessionToken } from './sonosCookie'; + +/** + * Sonos sign-in. Replaces the Electron auth BrowserWindow: the user signs in on play.sonos.com, + * and once the WebView lands on a signed-in page we read the HttpOnly session cookie natively and + * hand it up. The caller validates it (SessionManager.submit) - a false positive here is harmless. + */ +export function SonosLogin({ onToken }: { onToken: (token: string) => void }) { + const inFlight = useRef(false); + + const check = useCallback( + async (url: string) => { + if (inFlight.current || !looksSignedIn(url)) return; + inFlight.current = true; + try { + const token = await readSonosSessionToken(); + if (token) onToken(token); + } finally { + inFlight.current = false; + } + }, + [onToken], + ); + + return ( + void check(s.url)} + /> + ); +} diff --git a/apps/mobile/src/platform/core.ts b/apps/mobile/src/platform/core.ts new file mode 100644 index 0000000..40679c9 --- /dev/null +++ b/apps/mobile/src/platform/core.ts @@ -0,0 +1,18 @@ +// Process-wide core singleton. Importing this module installs `window.sonos` (the same surface the +// desktop preload exposes) BEFORE any ported hook mounts, so the hooks run unchanged. + +import { createCore } from '../core/createCore'; +import { createPlatform } from './createPlatform'; + +// React Native aliases `window` to the global object; make that explicit for safety. +const g = globalThis as unknown as { window?: unknown; sonos?: unknown }; +if (!g.window) g.window = globalThis; + +export const core = createCore(createPlatform()); +(globalThis as unknown as { window: { sonos: unknown } }).window.sonos = core.bridge; + +let startPromise: Promise<'authed' | 'needs-login'> | null = null; +/** Idempotent - safe under React dev double-mounting. */ +export function startCore() { + return (startPromise ??= core.start()); +} diff --git a/apps/mobile/src/platform/createPlatform.ts b/apps/mobile/src/platform/createPlatform.ts new file mode 100644 index 0000000..2350085 --- /dev/null +++ b/apps/mobile/src/platform/createPlatform.ts @@ -0,0 +1,32 @@ +import Constants from 'expo-constants'; +import { Linking } from 'react-native'; +import type { CorePlatform } from '../core/createCore'; +import { createPubSubSocket, createSonosSocket } from './sockets'; +import { secureKeyValueStore, secureTokenStore } from './secureStore'; + +// Not secret; same value the desktop app hardcodes. +const DEFAULT_FUNCTION_URL = 'https://truetunes-fn.azurewebsites.net'; + +/** + * The real React Native platform for the core. Optional integrations come from EXPO_PUBLIC_* env + * vars (see .env.example); like the desktop's build-env.json they are baked into the bundle, so + * they are as exposed as any client-side value. + */ +export function createPlatform(): CorePlatform { + return { + tokenStore: secureTokenStore, + kvStore: secureKeyValueStore, + createSonosSocket, + createPubSubSocket, + appVersion: Constants.expoConfig?.version ?? '0.0.0', + openExternal: (url) => Linking.openURL(url), + log: __DEV__ ? (...a) => console.log('[core]', ...a) : undefined, + env: { + pubsubFunctionUrl: process.env.EXPO_PUBLIC_PUBSUB_FUNCTION_URL || DEFAULT_FUNCTION_URL, + geniusToken: process.env.EXPO_PUBLIC_GENIUS_ACCESS_TOKEN || undefined, + appInsightsConnectionString: process.env.EXPO_PUBLIC_APPINSIGHTS_CONNECTION_STRING || undefined, + }, + // TODO: Entra sign-in needs an Android app registration (redirect URI) - see README. + entra: null, + }; +} diff --git a/apps/mobile/src/platform/queryPersister.ts b/apps/mobile/src/platform/queryPersister.ts new file mode 100644 index 0000000..866a9eb --- /dev/null +++ b/apps/mobile/src/platform/queryPersister.ts @@ -0,0 +1,36 @@ +import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'; +import { File, Paths } from 'expo-file-system'; + +// One JSON file in the app cache directory (Android may clear it under storage pressure, which is +// fine - it only costs a refetch). Every operation is best effort: a corrupt or unreadable cache +// must behave like an empty one, never as an error. +const file = new File(Paths.cache, 'query-cache.json'); + +export const queryPersister = createAsyncStoragePersister({ + key: 'true-tunes-query-cache', + throttleTime: 3000, + storage: { + getItem: async () => { + try { + return file.exists ? await file.text() : null; + } catch { + return null; + } + }, + setItem: async (_key, value) => { + try { + if (!file.exists) file.create(); + file.write(value); + } catch { + /* best effort */ + } + }, + removeItem: async () => { + try { + if (file.exists) file.delete(); + } catch { + /* best effort */ + } + }, + }, +}); diff --git a/apps/mobile/src/platform/secureStore.ts b/apps/mobile/src/platform/secureStore.ts new file mode 100644 index 0000000..93ed9fd --- /dev/null +++ b/apps/mobile/src/platform/secureStore.ts @@ -0,0 +1,20 @@ +// React Native adapters over expo-secure-store (Android Keystore-backed). Replaces Electron's +// safeStorage (session token) and the plaintext config.json in userData. + +import * as SecureStore from 'expo-secure-store'; +import type { TokenStore } from '../core/session'; + +const TOKEN_KEY = 'sonos.sessionToken'; + +export const secureTokenStore: TokenStore = { + load: () => SecureStore.getItemAsync(TOKEN_KEY), + save: (token) => SecureStore.setItemAsync(TOKEN_KEY, token), + clear: () => SecureStore.deleteItemAsync(TOKEN_KEY), +}; + +/** Key/value store for app config. SecureStore keys allow only [A-Za-z0-9._-]. */ +export const secureKeyValueStore = { + get: (key: string) => SecureStore.getItemAsync(key), + set: (key: string, value: string) => SecureStore.setItemAsync(key, value), + remove: (key: string) => SecureStore.deleteItemAsync(key), +}; diff --git a/apps/mobile/src/platform/sockets.ts b/apps/mobile/src/platform/sockets.ts new file mode 100644 index 0000000..e1bd23f --- /dev/null +++ b/apps/mobile/src/platform/sockets.ts @@ -0,0 +1,18 @@ +// WebSocket factories for React Native. RN's WebSocket takes a non-standard third `options` +// argument carrying request headers, which the Sonos endpoint needs (Origin, User-Agent). +// Verified on a Pixel 10 Pro XL in the Phase 0 spike. + +import type { SocketFactory, SocketLike } from '../sonos/wsSession'; + +type HeaderWebSocketCtor = new ( + url: string, + protocols: string[], + options: { headers: Record }, +) => WebSocket; + +export const createSonosSocket: SocketFactory = (url, protocols, headers) => + new (WebSocket as unknown as HeaderWebSocketCtor)(url, protocols, { headers }) as unknown as SocketLike; + +/** Azure Web PubSub takes a plain subprotocol and no custom headers. */ +export const createPubSubSocket = (url: string, protocol: string) => + new WebSocket(url, protocol) as unknown as SocketLike; diff --git a/apps/mobile/src/platform/sonosCookie.ts b/apps/mobile/src/platform/sonosCookie.ts new file mode 100644 index 0000000..e3ce293 --- /dev/null +++ b/apps/mobile/src/platform/sonosCookie.ts @@ -0,0 +1,12 @@ +import CookieManager from '@preeternal/react-native-cookie-manager'; +import { BASE_URL, SESSION_COOKIE } from '../sonos/constants'; + +/** The HttpOnly next-auth session token from the WebView's cookie jar (native read), or null. */ +export async function readSonosSessionToken(): Promise { + const cookies = await CookieManager.get(BASE_URL); + return cookies[SESSION_COOKIE]?.value ?? null; +} + +export async function clearSonosCookies(): Promise { + await CookieManager.clearAll(); +} diff --git a/apps/mobile/src/platform/useConnectionStatus.ts b/apps/mobile/src/platform/useConnectionStatus.ts new file mode 100644 index 0000000..5a9ad9d --- /dev/null +++ b/apps/mobile/src/platform/useConnectionStatus.ts @@ -0,0 +1,10 @@ +import { useEffect, useState } from 'react'; +import type { WsStatus } from '../sonos/wsSession'; +import { core } from './core'; + +/** Live Sonos WebSocket state. The hub replays the latest value, so late mounts are correct. */ +export function useConnectionStatus(): WsStatus { + const [status, setStatus] = useState('connecting'); + useEffect(() => core.hub.on('ws:status', setStatus), []); + return status; +} diff --git a/apps/mobile/src/sonos/__tests__/queueOps.test.ts b/apps/mobile/src/sonos/__tests__/queueOps.test.ts new file mode 100644 index 0000000..74cbb88 --- /dev/null +++ b/apps/mobile/src/sonos/__tests__/queueOps.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from 'vitest'; +import { computeReorderBatches, createQueueOps } from '../queueOps'; +import type { ReorderBatch } from '../queueOps'; +import type { SonosClient } from '../sonosClient'; + +/** + * Apply batches the way the API does (and the way the algorithm models it): each PATCH moves the + * contiguous run at `items` so it starts at `positions[0]` in the pre-move index space. + */ +function applyBatches(queue: number[], batches: ReorderBatch[]): number[] { + let q = [...queue]; + for (const { items, positions } of batches) { + const start = items[0]; + const len = items.length; + const finalPos = positions[0]; + const run = q.slice(start, start + len); + const rest = [...q.slice(0, start), ...q.slice(start + len)]; + const insertAt = finalPos <= start ? finalPos : finalPos - len; + q = [...rest.slice(0, insertAt), ...run, ...rest.slice(insertAt)]; + } + return q; +} + +/** What the user asked for: pull `from` out and drop them (in order) before the first survivor at/after `toIndex`. */ +function expectedOrder(n: number, from: number[], toIndex: number): number[] { + const sel = new Set(from); + const sorted = [...from].sort((a, b) => a - b); + const rest = Array.from({ length: n }, (_, i) => i).filter((i) => !sel.has(i)); + let anchor = rest.length; + for (let i = toIndex; i < n; i++) { + if (!sel.has(i)) { + anchor = rest.indexOf(i); + break; + } + } + return [...rest.slice(0, anchor), ...sorted, ...rest.slice(anchor)]; +} + +describe('computeReorderBatches', () => { + it('returns no batches when the order is unchanged', () => { + expect(computeReorderBatches([2], 2, 5)).toEqual([]); + expect(computeReorderBatches([2], 3, 5)).toEqual([]); // dropping after itself is a no-op + expect(computeReorderBatches([0, 1], 0, 4)).toEqual([]); + }); + + it('moves a single item earlier', () => { + expect(computeReorderBatches([3], 0, 5)).toEqual([{ items: [3], positions: [0] }]); + }); + + it('moves a single item later', () => { + const batches = computeReorderBatches([0], 4, 5); + expect(applyBatches([0, 1, 2, 3, 4], batches)).toEqual([1, 2, 3, 0, 4]); + }); + + it('moves a contiguous run as one batch', () => { + expect(computeReorderBatches([3, 4], 0, 6)).toEqual([{ items: [3, 4], positions: [0, 1] }]); + }); + + it('splits a non-contiguous selection into runs, one batch each', () => { + const batches = computeReorderBatches([3, 4, 7], 0, 9); + expect(batches).toHaveLength(2); + expect(applyBatches(Array.from({ length: 9 }, (_, i) => i), batches)).toEqual(expectedOrder(9, [3, 4, 7], 0)); + }); + + it('appends to the end when toIndex is past the last item', () => { + const batches = computeReorderBatches([1], 5, 5); + expect(applyBatches([0, 1, 2, 3, 4], batches)).toEqual([0, 2, 3, 4, 1]); + }); + + it('treats the selection as order-independent', () => { + expect(computeReorderBatches([7, 3, 4], 0, 9)).toEqual(computeReorderBatches([3, 4, 7], 0, 9)); + }); + + it('never touches the input array', () => { + const from = [5, 1]; + computeReorderBatches(from, 0, 6); + expect(from).toEqual([5, 1]); + }); + + it('produces the requested order for every selection of a small queue (exhaustive)', () => { + const n = 6; + let checked = 0; + for (let mask = 1; mask < 1 << n; mask++) { + const from = Array.from({ length: n }, (_, i) => i).filter((i) => mask & (1 << i)); + for (let toIndex = 0; toIndex <= n; toIndex++) { + const batches = computeReorderBatches(from, toIndex, n); + const got = applyBatches(Array.from({ length: n }, (_, i) => i), batches); + expect(got, `from=[${from}] to=${toIndex}`).toEqual(expectedOrder(n, from, toIndex)); + checked++; + } + } + expect(checked).toBe(63 * 7); + }); + + it('holds for random selections of larger queues', () => { + // Small deterministic LCG so failures reproduce. + let seed = 12345; + const rand = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; + for (let t = 0; t < 300; t++) { + const n = 2 + Math.floor(rand() * 30); + const k = 1 + Math.floor(rand() * Math.min(n, 8)); + const from = [...new Set(Array.from({ length: k }, () => Math.floor(rand() * n)))]; + const toIndex = Math.floor(rand() * (n + 1)); + const got = applyBatches(Array.from({ length: n }, (_, i) => i), computeReorderBatches(from, toIndex, n)); + expect(got, `n=${n} from=[${from}] to=${toIndex}`).toEqual(expectedOrder(n, from, toIndex)); + } + }); +}); + +function makeOps(fetchResults: Array<{ error?: string; data?: unknown }> = []) { + const queue = [...fetchResults]; + const fetch = vi.fn(async () => queue.shift() ?? { data: null }); + const config = {} as { queueId?: string }; + const ops = createQueueOps({ client: { fetch } as unknown as SonosClient, config }); + return { ops, fetch, config }; +} + +describe('queue ops', () => { + it('reorder validates its inputs and never calls the API on bad input', async () => { + const { ops, fetch } = makeOps(); + expect(await ops.reorder([-1], 0, 5)).toEqual({ error: 'Invalid fromIndices' }); + expect(await ops.reorder('x' as never, 0, 5)).toEqual({ error: 'Invalid fromIndices' }); + expect(await ops.reorder([1], -1, 5)).toEqual({ error: 'Invalid toIndex' }); + expect(await ops.reorder([1], 0, 0)).toEqual({ error: 'Invalid queueLength' }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('reorder with an empty selection is a no-op success', async () => { + const { ops, fetch } = makeOps(); + expect(await ops.reorder([], 0, 5)).toEqual({ ok: true }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('reorder sends one PATCH per batch with comma-joined items and positions', async () => { + const { ops, fetch } = makeOps(); + expect(await ops.reorder([3, 4], 0, 6)).toEqual({ ok: true }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith({ + operationId: 'reorderQueueResources', + query: { items: '3,4', positions: '0,1' }, + }); + }); + + it('reorder stops at the first failing batch and returns its error', async () => { + const { ops, fetch } = makeOps([{ data: null }, { error: '412 Precondition Failed' }, { data: null }]); + const res = await ops.reorder([1, 4, 7], 0, 9); + expect(res).toEqual({ error: '412 Precondition Failed' }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('reorder is a successful no-op when nothing needs to move', async () => { + const { ops, fetch } = makeOps(); + expect(await ops.reorder([2], 2, 5)).toEqual({ ok: true }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('remove sends the indices and validates them', async () => { + const { ops, fetch } = makeOps(); + await ops.remove([2, 5]); + expect(fetch).toHaveBeenCalledWith({ operationId: 'deleteQueueResources', query: { items: '2,5' } }); + expect(await ops.remove([-2])).toEqual({ error: 'Invalid indices' }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('clear deletes with no items filter', async () => { + const { ops, fetch } = makeOps(); + await ops.clear(); + expect(fetch).toHaveBeenCalledWith({ operationId: 'deleteQueueResources' }); + }); + + it('setQueueId only accepts non-empty strings', () => { + const { ops, config } = makeOps(); + ops.setQueueId('RINCON_X'); + expect(config.queueId).toBe('RINCON_X'); + ops.setQueueId(''); + ops.setQueueId(5 as never); + expect(config.queueId).toBe('RINCON_X'); + }); +}); diff --git a/apps/mobile/src/sonos/__tests__/sonosClient.test.ts b/apps/mobile/src/sonos/__tests__/sonosClient.test.ts new file mode 100644 index 0000000..91c8bc2 --- /dev/null +++ b/apps/mobile/src/sonos/__tests__/sonosClient.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PLATFORM_ACCOUNT_ID, PLATFORM_SERVICE_ID } from '../operations'; +import { buildUrl, createSonosClient, parseIntegrationsCreds, timezoneOffset } from '../sonosClient'; +import type { SonosConfig } from '../types'; + +const json = (body: unknown, init: ResponseInit = {}) => + new Response(JSON.stringify(body), { status: 200, ...init }); + +function makeClient( + responses: Array, + overrides: Partial[0]> = {}, +) { + const config: SonosConfig = { householdId: 'HH', serviceId: 'SVC', accountId: 'ACC', groupId: 'G:1', queueId: 'Q' }; + const queue = [...responses]; + const fetchImpl = vi.fn(async () => { + const next = queue.shift(); + if (!next) throw new Error('unexpected extra fetch'); + if (next instanceof Error) throw next; + return next; + }); + const onUnauthorized = vi.fn(); + const onEvent = vi.fn(); + const client = createSonosClient({ + config, + getSessionToken: () => 'TOKEN', + onUnauthorized, + onEvent, + fetchImpl: fetchImpl as unknown as typeof fetch, + sleep: async () => {}, + timezone: () => '+01:00', + ...overrides, + }); + return { client, config, fetchImpl, onUnauthorized, onEvent }; +} + +describe('buildUrl', () => { + const config: SonosConfig = { householdId: 'HH', serviceId: 'SVC', accountId: 'ACC', groupId: 'RINCON_1:23', queueId: 'RINCON_1' }; + + it('fills config and platform defaults and URL-encodes values', () => { + const url = buildUrl( + '/api/content/v1/groups/:groupId/services/:platformServiceId/accounts/:platformAccountId/queues/:queueId/resources', + config, + ); + expect(url).toBe( + `https://play.sonos.com/api/content/v1/groups/RINCON_1%3A23/services/${PLATFORM_SERVICE_ID}/accounts/${PLATFORM_ACCOUNT_ID}/queues/RINCON_1/resources`, + ); + }); + + it('lets explicit pathParams override config', () => { + expect(buildUrl('/api/playback/v1/groups/:groupId', config, { groupId: 'OTHER' })).toBe( + 'https://play.sonos.com/api/playback/v1/groups/OTHER', + ); + }); + + it('drops undefined query values and encodes the rest', () => { + expect(buildUrl('/x', config, {}, { a: 'b c', skip: undefined, n: '1' })).toBe('https://play.sonos.com/x?a=b%20c&n=1'); + }); + + it('throws on a missing path param', () => { + expect(() => buildUrl('/a/:albumId', config)).toThrow('Missing path param: albumId'); + }); +}); + +describe('timezoneOffset', () => { + it('formats the local offset as +HH:MM / -HH:MM', () => { + const fake = (offsetMinutes: number) => ({ getTimezoneOffset: () => offsetMinutes }) as Date; + expect(timezoneOffset(fake(-60))).toBe('+01:00'); // UTC+1 reports -60 + expect(timezoneOffset(fake(0))).toBe('+00:00'); + expect(timezoneOffset(fake(300))).toBe('-05:00'); + expect(timezoneOffset(fake(-330))).toBe('+05:30'); + }); +}); + +describe('parseIntegrationsCreds', () => { + it('accepts hyphenated and camelCase keys, numbers as strings, and skips junk', () => { + expect( + parseIntegrationsCreds([ + { 'service-id': 72711, 'account-id': '13' }, + { serviceId: 'a', accountId: 'b' }, + { 'service-id': 'only' }, + null, + 'nope', + ]), + ).toEqual([ + { serviceId: '72711', accountId: '13' }, + { serviceId: 'a', accountId: 'b' }, + ]); + }); + + it('wraps a single object', () => { + expect(parseIntegrationsCreds({ serviceId: 's', accountId: 'a' })).toHaveLength(1); + }); +}); + +describe('sonosFetch', () => { + it('returns an error without a session token, without calling fetch', async () => { + const { client, fetchImpl } = makeClient([], { getSessionToken: () => null }); + expect(await client.fetch({ operationId: 'getGroups' })).toEqual({ error: 'No session token — please log in first' }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('rejects unknown operations', async () => { + const { client } = makeClient([]); + expect(await client.fetch({ operationId: 'nope' })).toEqual({ error: 'Unknown operation: nope' }); + }); + + it('sends the cookie, spoofed UA and JSON body, and returns data with the etag', async () => { + const { client, fetchImpl } = makeClient([json({ ok: 1 }, { headers: { etag: '"e1"' } })]); + const res = await client.fetch({ operationId: 'addQueueResource', body: { a: 1 }, headers: { 'If-Match': '"x"' } }); + expect(res).toEqual({ data: { ok: 1 }, etag: '"e1"' }); + + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toContain('/queues/Q/resources'); + expect(init.method).toBe('POST'); + expect(init.body).toBe('{"a":1}'); + const h = init.headers as Record; + expect(h['Cookie']).toBe('__Secure-next-auth.session-token=TOKEN'); + expect(h['User-Agent']).toContain('Chrome/'); + expect(h['X-Sonos-Timezone']).toBe('+01:00'); + expect(h['Content-Type']).toBe('application/json'); + expect(h['If-Match']).toBe('"x"'); + }); + + it('returns data:null for an empty body', async () => { + const { client } = makeClient([new Response('', { status: 200 })]); + expect(await client.fetch({ operationId: 'getGroups' })).toEqual({ data: null }); + }); + + it('reports a non-JSON success body', async () => { + const { client } = makeClient([new Response('', { status: 200 })]); + const res = await client.fetch({ operationId: 'getGroups' }); + expect(res.error).toMatch(/^Response is not JSON \(200/); + }); + + it('retries GET on 5xx then succeeds, emitting api_retry', async () => { + const { client, fetchImpl, onEvent } = makeClient([ + new Response('boom', { status: 503 }), + new Response('boom', { status: 500 }), + json({ fine: true }), + ]); + expect(await client.fetch({ operationId: 'getGroups' })).toEqual({ data: { fine: true } }); + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(onEvent).toHaveBeenCalledWith('api_retry', { operationId: 'getGroups', attempt: 1, statusCode: 503 }); + expect(onEvent).toHaveBeenCalledWith('api_retry', { operationId: 'getGroups', attempt: 2, statusCode: 500 }); + }); + + it('retries GET on a network error', async () => { + const { client, fetchImpl } = makeClient([new Error('socket hang up'), json({ ok: true })]); + expect(await client.fetch({ operationId: 'getGroups' })).toEqual({ data: { ok: true } }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('gives up after two retries and returns the last error', async () => { + const { client, fetchImpl } = makeClient([ + new Response('x', { status: 502 }), + new Response('x', { status: 502 }), + new Response('final', { status: 502, statusText: 'Bad Gateway' }), + ]); + const res = await client.fetch({ operationId: 'getGroups' }); + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(res.error).toContain('502'); + expect(res.error).toContain('final'); + }); + + it('returns a network error message once retries are exhausted', async () => { + const { client } = makeClient([new Error('a'), new Error('b'), new Error('c')]); + expect(await client.fetch({ operationId: 'getGroups' })).toEqual({ error: 'Fetch failed: c' }); + }); + + it.each(['addQueueResource', 'deleteQueueResources', 'reorderQueueResources', 'play'])( + 'never retries non-idempotent %s', + async (operationId) => { + const { client, fetchImpl } = makeClient([new Response('down', { status: 503 })]); + const res = await client.fetch({ operationId, body: {} }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(res.error).toContain('503'); + }, + ); + + it('does not retry a 4xx', async () => { + const { client, fetchImpl } = makeClient([new Response('nope', { status: 404, statusText: 'Not Found' })]); + expect((await client.fetch({ operationId: 'getGroups' })).error).toBe('404 Not Found: nope'); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('truncates long error bodies to 200 chars', async () => { + const { client } = makeClient([new Response('z'.repeat(500), { status: 400, statusText: 'Bad Request' })]); + const res = await client.fetch({ operationId: 'getGroups' }); + expect(res.error).toBe(`400 Bad Request: ${'z'.repeat(200)}`); + }); + + it('on 401 signals unauthorized and emits auth_expired', async () => { + const { client, onUnauthorized, onEvent } = makeClient([new Response('', { status: 401 })]); + const res = await client.fetch({ operationId: 'getGroups' }); + expect(res).toEqual({ error: '401 Unauthorized — re-authenticating' }); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith('auth_expired', { operationId: 'getGroups' }); + }); + + it('returns a missing-param error that is not about serviceId without discovering', async () => { + const { client, fetchImpl } = makeClient([]); + const res = await client.fetch({ operationId: 'browseAlbum', pathParams: {} }); + // serviceId is present in config, so the missing one is albumId. + expect(res).toEqual({ error: 'Missing path param: albumId' }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe('service discovery', () => { + it('discovers serviceId on demand, persists it, and retries the original call once', async () => { + const onConfigChanged = vi.fn(); + const { client, config, fetchImpl } = makeClient( + [json([{ 'service-id': 'S9', 'account-id': 'A9' }, { 'service-id': 'S2', 'account-id': 'A2' }]), json({ album: true })], + { onConfigChanged }, + ); + delete config.serviceId; + delete config.accountId; + + const res = await client.fetch({ operationId: 'browseAlbum', pathParams: { albumId: 'ALB' } }); + expect(res).toEqual({ data: { album: true } }); + expect(config).toMatchObject({ serviceId: 'S9', accountId: 'A9' }); + expect(onConfigChanged).toHaveBeenCalledTimes(1); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect((fetchImpl.mock.calls[0] as unknown as [string])[0]).toContain('/integrations/registrations'); + }); + + it('fails cleanly when discovery finds nothing', async () => { + const { client, config } = makeClient([json([])]); + delete config.serviceId; + const res = await client.fetch({ operationId: 'browseAlbum', pathParams: { albumId: 'ALB' } }); + expect(res).toEqual({ error: 'Missing path param: serviceId' }); + }); + + it('coalesces concurrent discovery onto one request', async () => { + const { client, config, fetchImpl } = makeClient([json([{ 'service-id': 'S', 'account-id': 'A' }])]); + delete config.serviceId; + await Promise.all([client.discoverServiceCredentials(), client.discoverServiceCredentials()]); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(config.serviceId).toBe('S'); + }); + + it('is a no-op when serviceId is already known', async () => { + const { client, fetchImpl } = makeClient([]); + await client.discoverServiceCredentials(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe('household discovery race', () => { + it('waits for the household, then retries the original call once', async () => { + let resolveHousehold!: () => void; + const awaitHousehold = vi.fn(() => new Promise((r) => (resolveHousehold = r))); + const { client, config, fetchImpl } = makeClient([json({ groups: [] })], { awaitHousehold }); + delete config.householdId; + + const pending = client.fetch({ operationId: 'searchHousehold', query: { query: 'x' } }); + await Promise.resolve(); + expect(fetchImpl).not.toHaveBeenCalled(); + + config.householdId = 'HH-LATE'; // the WS bootstrap finishes + resolveHousehold(); + expect(await pending).toEqual({ data: { groups: [] } }); + expect((fetchImpl.mock.calls[0] as unknown as [string])[0]).toContain('/households/HH-LATE/search'); + expect(awaitHousehold).toHaveBeenCalledTimes(1); + }); + + it('reports the original error, without waiting again, if the household never arrives', async () => { + const awaitHousehold = vi.fn(async () => {}); + const { client, config, fetchImpl } = makeClient([], { awaitHousehold }); + delete config.householdId; + const res = await client.fetch({ operationId: 'searchHousehold' }); + expect(res).toEqual({ error: 'Missing path param: householdId' }); + expect(awaitHousehold).toHaveBeenCalledTimes(1); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('fails fast with no awaitHousehold hook', async () => { + const { client, config } = makeClient([]); + delete config.householdId; + expect(await client.fetch({ operationId: 'searchHousehold' })).toEqual({ error: 'Missing path param: householdId' }); + }); +}); diff --git a/apps/mobile/src/sonos/__tests__/wsSession.test.ts b/apps/mobile/src/sonos/__tests__/wsSession.test.ts new file mode 100644 index 0000000..b0643c3 --- /dev/null +++ b/apps/mobile/src/sonos/__tests__/wsSession.test.ts @@ -0,0 +1,512 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WsSession } from '../wsSession'; +import type { SocketLike, WsBroadcast, WsConfig } from '../wsSession'; + +type Frame = [Record, unknown]; + +class FakeSocket implements SocketLike { + readyState = 0; + sent: Frame[] = []; + onopen: (() => void) | null = null; + onmessage: ((e: { data: unknown }) => void) | null = null; + onerror: ((e: { message?: string }) => void) | null = null; + onclose: ((e: { code: number }) => void) | null = null; + onSend: ((frame: Frame) => void) | null = null; + closeCalls: Array = []; + + constructor( + public url: string, + public protocols: string[], + public headers: Record, + ) {} + + send(data: string) { + const frame = JSON.parse(data) as Frame; + this.sent.push(frame); + this.onSend?.(frame); + } + close(code?: number) { + this.closeCalls.push(code); + this.readyState = 3; + this.onclose?.({ code: code ?? 1005 }); + } + // ── test helpers ── + open() { + this.readyState = 1; + this.onopen?.(); + } + push(header: Record, payload: unknown = {}) { + this.onmessage?.({ data: JSON.stringify([header, payload]) }); + } + commands() { + return this.sent.map(([h]) => `${h['namespace']}:${h['command']}`); + } +} + +const TICKET = '11111111-2222-3333-4444-555555555555'; + +const DEVICES = { + devices: [ + { info: { id: 'RINCON_A', name: 'Office' }, groupId: 'RINCON_A:1', isCoordinator: true }, + { info: { id: 'RINCON_A2', name: 'Office 2' }, groupId: 'RINCON_A:1', isCoordinator: false }, + { info: { id: 'RINCON_B', name: 'Bathroom' }, groupId: 'RINCON_B:2', isCoordinator: true }, + ], +}; + +/** Answers every command like Sonos does, including the devices push after subscribe. */ +function autoRespond(sock: FakeSocket, opts: { skipDevicesPush?: boolean } = {}) { + sock.onSend = ([h]) => { + const reply = (payload: unknown) => sock.push({ corrId: h['corrId'], namespace: h['namespace'] }, payload); + if (h['command'] === 'getHouseholds') return reply({ households: [{ id: 'HH1' }] }); + if (h['namespace'] === 'devices' && h['command'] === 'subscribe') { + reply({}); + if (!opts.skipDevicesPush) sock.push({ namespace: 'devices', name: 'devicesStatus' }, DEVICES); + return; + } + if (h['namespace'] === 'playbackExtended') return reply({ playback: { playbackState: `INITIAL_${h['groupId']}` } }); + if (h['namespace'] === 'groupVolume') return reply({ volume: h['groupId'] === 'RINCON_A:1' ? 15 : 9 }); + reply({}); + }; +} + +function setup(configOverride: Partial = {}, fetchImpl?: typeof fetch) { + const config: WsConfig = { ...configOverride }; + const sockets: FakeSocket[] = []; + const broadcasts: WsBroadcast[] = []; + const onConfigChanged = vi.fn(); + const onPlaybackState = vi.fn(); + const onEvent = vi.fn(); + const onException = vi.fn(); + const onStatus = vi.fn(); + const discover = vi.fn(async () => {}); + const mfeFetch = vi.fn(async () => new Response(JSON.stringify({ app: { auth: { wsTicket: TICKET } } }), { status: 200 })); + + const session = new WsSession({ + config, + getSessionToken: () => 'TOKEN', + createSocket: (url, protocols, headers) => { + const s = new FakeSocket(url, protocols, headers); + sockets.push(s); + return s; + }, + discoverServiceCredentials: discover, + broadcast: (m) => broadcasts.push(m), + onConfigChanged, + onPlaybackState, + onEvent, + onException, + onStatus, + fetchImpl: fetchImpl ?? (mfeFetch as unknown as typeof fetch), + }); + return { session, config, sockets, broadcasts, onConfigChanged, onPlaybackState, onEvent, onException, onStatus, discover, mfeFetch }; +} + +const settle = async () => { + for (let i = 0; i < 5; i++) await vi.advanceTimersByTimeAsync(0); +}; + +/** connect(), open the socket and let the bootstrap finish. */ +async function connected(ctx: ReturnType, opts?: { skipDevicesPush?: boolean }) { + await ctx.session.connect(); + const sock = ctx.sockets[ctx.sockets.length - 1]; + autoRespond(sock, opts); + sock.open(); + await settle(); + return sock; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('connect', () => { + it('fetches /api/mfe with the session cookie and opens the socket with ticket, subprotocol and Origin', async () => { + const ctx = setup(); + await ctx.session.connect(); + const [url, init] = ctx.mfeFetch.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://play.sonos.com/api/mfe'); + expect((init.headers as Record)['Cookie']).toBe('__Secure-next-auth.session-token=TOKEN'); + + const sock = ctx.sockets[0]; + expect(sock.url).toBe(`wss://api.ws.sonos.com/websocket?ticket=${TICKET}`); + expect(sock.protocols).toEqual(['v1.api.smartspeaker.audio']); + expect(sock.headers['Origin']).toBe('https://play.sonos.com'); + expect(sock.headers['User-Agent']).toContain('Chrome/'); + }); + + it('does not open a second socket while one is connecting or open', async () => { + const ctx = setup(); + await ctx.session.connect(); + await ctx.session.connect(); + expect(ctx.sockets).toHaveLength(1); + }); + + it('schedules a reconnect and reports when the ticket fetch fails', async () => { + const ctx = setup({}, vi.fn(async () => new Response('no', { status: 401 })) as unknown as typeof fetch); + await ctx.session.connect(); + expect(ctx.sockets).toHaveLength(0); + expect(ctx.onException).toHaveBeenCalledWith(expect.any(Error), { phase: 'ws_ticket_fetch' }); + }); + + it('emits ws_ticket_missing when the MFE response has no ticket', async () => { + const ctx = setup({}, vi.fn(async () => new Response('{"nothing":true}', { status: 200 })) as unknown as typeof fetch); + await ctx.session.connect(); + expect(ctx.onEvent).toHaveBeenCalledWith('ws_ticket_missing'); + expect(ctx.sockets).toHaveLength(0); + }); +}); + +describe('bootstrap', () => { + it('subscribes in the desktop order and broadcasts ready, groups and the active group state', async () => { + const ctx = setup(); + const sock = await connected(ctx); + + expect(sock.commands().slice(0, 2)).toEqual(['households:getHouseholds', 'devices:subscribe']); + const rest = sock.commands().slice(2); + expect(rest.filter((c) => c === 'playbackExtended:subscribe')).toHaveLength(2); + expect(rest.filter((c) => c === 'groupVolume:subscribe')).toHaveLength(2); + expect(rest.filter((c) => c === 'playerVolume:subscribe')).toHaveLength(3); + expect(rest.filter((c) => c === 'hardwareStatus:subscribe')).toHaveLength(3); + // devices subscribe carries the discovered household + expect(sock.sent[1][0]['householdId']).toBe('HH1'); + + expect(ctx.discover).toHaveBeenCalledTimes(1); + expect(ctx.config.householdId).toBe('HH1'); + + // Command responses are forwarded as ws:message too (as on desktop), so only check ordering. + const lifecycle = ctx.broadcasts.map((b) => b.channel).filter((c) => c !== 'ws:message'); + expect(lifecycle).toEqual(['ws:ready', 'ws:groups']); + const groupsMsg = ctx.broadcasts.find((b) => b.channel === 'ws:groups') as Extract; + expect(groupsMsg.groups.map((g) => g.name)).toEqual(['Office', 'Bathroom']); + expect(groupsMsg.groups[0].playerIds).toEqual(['RINCON_A', 'RINCON_A2']); + + // Initial state for the active group only, re-broadcast as ordinary pushes. + const seeded = ctx.broadcasts.filter((b) => b.channel === 'ws:message') as Array>; + const pb = seeded.filter((m) => m.header['namespace'] === 'playbackExtended' && m.payload && JSON.stringify(m.payload).includes('INITIAL_RINCON_A:1')); + expect(pb.length).toBeGreaterThan(0); + expect(seeded.some((m) => m.header['namespace'] === 'groupVolume' && (m.payload as { volume: number }).volume === 15)).toBe(true); + expect(ctx.onEvent).toHaveBeenCalledWith('ws_connected', expect.objectContaining({ groupCount: 2 })); + }); + + it('defaults to the first group and persists it as the preference', async () => { + const ctx = setup(); + await connected(ctx); + expect(ctx.config.groupId).toBe('RINCON_A:1'); + expect(ctx.config.queueId).toBe('RINCON_A'); + expect(ctx.config.preferredCoordinatorId).toBe('RINCON_A'); + expect(ctx.onConfigChanged).toHaveBeenCalledTimes(1); + }); + + it('restores the saved group by stable coordinatorId and does not re-persist', async () => { + const ctx = setup({ preferredCoordinatorId: 'RINCON_B' }); + await connected(ctx); + expect(ctx.config.groupId).toBe('RINCON_B:2'); + expect(ctx.config.queueId).toBe('RINCON_B'); + expect(ctx.onConfigChanged).not.toHaveBeenCalled(); + }); + + it('falls back to the first group when the saved coordinator has left the household', async () => { + const ctx = setup({ preferredCoordinatorId: 'RINCON_GONE' }); + await connected(ctx); + expect(ctx.config.groupId).toBe('RINCON_A:1'); + expect(ctx.config.preferredCoordinatorId).toBe('RINCON_A'); + }); + + it('closes the socket and reports when bootstrap fails (devices push never arrives)', async () => { + const ctx = setup(); + const sock = await connected(ctx, { skipDevicesPush: true }); + await vi.advanceTimersByTimeAsync(10_000); + await settle(); + expect(ctx.onException).toHaveBeenCalledWith(expect.any(Error), { phase: 'ws_bootstrap' }); + expect(sock.closeCalls).toHaveLength(1); + }); +}); + +describe('message routing', () => { + it('forwards only the active group for playbackExtended and groupVolume', async () => { + const ctx = setup(); + const sock = await connected(ctx); + ctx.broadcasts.length = 0; + + sock.push({ namespace: 'playbackExtended', groupId: 'RINCON_B:2' }, { playback: { playbackState: 'PLAYBACK_STATE_PLAYING' } }); + sock.push({ namespace: 'groupVolume', groupId: 'RINCON_B:2' }, { volume: 1 }); + expect(ctx.broadcasts).toHaveLength(0); + + sock.push({ namespace: 'playbackExtended', groupId: 'RINCON_A:1' }, { playback: { playbackState: 'PLAYBACK_STATE_PAUSED' } }); + sock.push({ namespace: 'groupVolume', groupId: 'RINCON_A:1' }, { volume: 22 }); + expect(ctx.broadcasts.map((b) => b.channel)).toEqual(['ws:message', 'ws:message']); + }); + + it('forwards other namespaces regardless of group', async () => { + const ctx = setup(); + const sock = await connected(ctx); + ctx.broadcasts.length = 0; + sock.push({ namespace: 'playerVolume', groupId: 'RINCON_B:2' }, {}); + expect(ctx.broadcasts).toHaveLength(1); + }); + + it('reports play state from playbackExtended pushes', async () => { + const ctx = setup(); + const sock = await connected(ctx); + sock.push({ namespace: 'playbackExtended', groupId: 'RINCON_A:1' }, { playback: { playbackState: 'PLAYBACK_STATE_PLAYING' } }); + expect(ctx.onPlaybackState).toHaveBeenLastCalledWith(true); + sock.push({ namespace: 'playbackExtended', groupId: 'RINCON_A:1' }, { playback: { playbackState: 'PLAYBACK_STATE_BUFFERING' } }); + expect(ctx.onPlaybackState).toHaveBeenLastCalledWith(false); + }); + + it('ignores malformed frames', async () => { + const ctx = setup(); + const sock = await connected(ctx); + ctx.broadcasts.length = 0; + sock.onmessage?.({ data: 'not json' }); + expect(ctx.broadcasts).toHaveLength(0); + }); +}); + +describe('send', () => { + it('resolves with the payload of the frame carrying the same corrId', async () => { + const ctx = setup(); + const sock = await connected(ctx); + sock.onSend = ([h]) => sock.push({ corrId: h['corrId'] }, { echoed: h['command'] }); + await expect(ctx.session.send({ namespace: 'x', command: 'ping' }, {})).resolves.toEqual({ echoed: 'ping' }); + }); + + it('rejects when the socket is not open', async () => { + const ctx = setup(); + await expect(ctx.session.send({ namespace: 'x', command: 'y' }, {})).rejects.toThrow('WebSocket not open'); + }); + + it('times out after 10s and emits ws_timeout', async () => { + const ctx = setup(); + const sock = await connected(ctx); + sock.onSend = null; // never answer + const p = ctx.session.send({ namespace: 'playback', command: 'play' }, {}); + const assertion = expect(p).rejects.toThrow(/WS timeout/); + await vi.advanceTimersByTimeAsync(10_000); + await assertion; + expect(ctx.onEvent).toHaveBeenCalledWith('ws_timeout', { namespace: 'playback', command: 'play' }); + }); +}); + +describe('reconnect', () => { + it('backs off 1s, 2s, 4s and resets after a successful open', async () => { + const ctx = setup(); + await ctx.session.connect(); + const first = ctx.sockets[0]; + first.close(1006); + expect(ctx.sockets).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(999); + expect(ctx.sockets).toHaveLength(1); + await vi.advanceTimersByTimeAsync(1); + expect(ctx.sockets).toHaveLength(2); + + ctx.sockets[1].close(1006); // second failure -> 2s + await vi.advanceTimersByTimeAsync(1999); + expect(ctx.sockets).toHaveLength(2); + await vi.advanceTimersByTimeAsync(1); + expect(ctx.sockets).toHaveLength(3); + + // A successful open resets the backoff to 1s. + autoRespond(ctx.sockets[2]); + ctx.sockets[2].open(); + await settle(); + ctx.sockets[2].close(1006); + await vi.advanceTimersByTimeAsync(1000); + expect(ctx.sockets).toHaveLength(4); + }); + + it('emits ws_closed for abnormal closes only', async () => { + const ctx = setup(); + await ctx.session.connect(); + ctx.sockets[0].close(1006); + expect(ctx.onEvent).toHaveBeenCalledWith('ws_closed', { code: 1006 }); + ctx.onEvent.mockClear(); + await vi.advanceTimersByTimeAsync(1000); + ctx.sockets[1].close(1000); + expect(ctx.onEvent).not.toHaveBeenCalledWith('ws_closed', expect.anything()); + }); + + it('stop() closes the socket and prevents any reconnect', async () => { + const ctx = setup(); + const sock = await connected(ctx); + ctx.session.stop(); + expect(sock.closeCalls).toEqual([1000]); + await vi.advanceTimersByTimeAsync(120_000); + expect(ctx.sockets).toHaveLength(1); + }); + + it('a late close from a replaced socket does not clobber the live one', async () => { + const ctx = setup(); + await ctx.session.connect(); + ctx.sockets[0].close(1006); + await vi.advanceTimersByTimeAsync(1000); + const live = ctx.sockets[1]; + autoRespond(live); + live.open(); + await settle(); + expect(ctx.session.isOpen).toBe(true); + + ctx.sockets[0].onclose?.({ code: 1006 }); // stale event from the first socket + expect(ctx.session.isOpen).toBe(true); + }); +}); + +describe('commands', () => { + it('returns WS not connected when closed', async () => { + const ctx = setup(); + expect(ctx.session.play()).toEqual({ error: 'WS not connected' }); + expect(ctx.session.skipNext()).toEqual({ error: 'WS not connected' }); + }); + + it.each([ + ['play', 'playback', 'play', { allowTvPauseRestore: true, deviceFeedback: 'NONE' }, []], + ['pause', 'playback', 'pause', { allowTvPauseRestore: true, deviceFeedback: 'NONE' }, []], + ['skipNext', 'playback', 'skipToNextTrack', {}, []], + ['skipPrev', 'playback', 'skipBack', {}, []], + ['skipToTrack', 'playback', 'skipToTrack', { trackNumber: 4 }, [4]], + ['seek', 'playback', 'seek', { positionMillis: 12000 }, [12000]], + ['setPlayModes', 'playback', 'setPlayModes', { playModes: { shuffle: true } }, [{ shuffle: true }]], + ['loadContent', 'playback', 'loadContent', { a: 1 }, [{ a: 1 }]], + ['setGroupVolume', 'groupVolume', 'setVolume', { volume: 30 }, [30]], + ] as const)('%s sends %s:%s to the active group', async (method, namespace, command, payload, args) => { + const ctx = setup(); + const sock = await connected(ctx); + sock.sent.length = 0; + await (ctx.session[method] as (...a: unknown[]) => Promise)(...args); + expect(sock.sent).toHaveLength(1); + const [header, body] = sock.sent[0]; + expect(header).toMatchObject({ namespace, command, groupId: 'RINCON_A:1' }); + expect(body).toEqual(payload); + }); + + it('validates volume', async () => { + const ctx = setup(); + await connected(ctx); + expect(ctx.session.setGroupVolume(101)).toEqual({ error: 'Invalid volume' }); + expect(ctx.session.setGroupVolume(-1)).toEqual({ error: 'Invalid volume' }); + expect(ctx.session.setGroupVolume(NaN as never)).toEqual({ error: 'Invalid volume' }); + }); + + it('setGroup switches group, persists the preference and re-subscribes playback and volume', async () => { + const ctx = setup(); + const sock = await connected(ctx); + ctx.onConfigChanged.mockClear(); + sock.sent.length = 0; + + expect(ctx.session.setGroup('RINCON_B:2')).toEqual({ ok: true }); + expect(ctx.config.groupId).toBe('RINCON_B:2'); + expect(ctx.config.queueId).toBe('RINCON_B'); + expect(ctx.config.preferredCoordinatorId).toBe('RINCON_B'); + expect(ctx.onConfigChanged).toHaveBeenCalledTimes(1); + expect(sock.sent.map(([h]) => `${h['namespace']}:${h['command']}:${h['groupId']}`)).toEqual([ + 'playbackExtended:subscribe:RINCON_B:2', + 'groupVolume:subscribe:RINCON_B:2', + ]); + expect(ctx.session.getActiveGroup()).toBe('RINCON_B'); + }); + + it('setGroup rejects unknown groups without changing anything', async () => { + const ctx = setup(); + await connected(ctx); + expect(ctx.session.setGroup('nope')).toEqual({ error: 'Unknown group: nope' }); + expect(ctx.config.groupId).toBe('RINCON_A:1'); + }); +}); + +describe('resync', () => { + it('reconnects when the socket is down', async () => { + const ctx = setup(); + ctx.session.resync(); + await settle(); + expect(ctx.sockets).toHaveLength(1); + }); + + it('re-broadcasts ready/groups and re-subscribes playback when up', async () => { + const ctx = setup(); + const sock = await connected(ctx); + ctx.broadcasts.length = 0; + sock.sent.length = 0; + ctx.session.resync(); + expect(ctx.broadcasts.map((b) => b.channel).filter((c) => c !== 'ws:message')).toEqual(['ws:ready', 'ws:groups']); + expect(sock.commands()).toEqual(['playbackExtended:subscribe']); + }); +}); + +describe('whenHousehold', () => { + it('resolves immediately when the household is already known', async () => { + const ctx = setup({ householdId: 'HH0' }); + await expect(ctx.session.whenHousehold()).resolves.toBeUndefined(); + }); + + it('resolves as soon as the bootstrap discovers the household', async () => { + const ctx = setup(); + let done = false; + void ctx.session.whenHousehold().then(() => (done = true)); + await settle(); + expect(done).toBe(false); + await connected(ctx); + expect(done).toBe(true); + expect(ctx.config.householdId).toBe('HH1'); + }); + + it('gives up after the timeout without rejecting', async () => { + const ctx = setup(); + const p = ctx.session.whenHousehold(5_000); + await vi.advanceTimersByTimeAsync(5_000); + await expect(p).resolves.toBeUndefined(); + expect(ctx.config.householdId).toBeUndefined(); + }); +}); + +describe('connection status', () => { + it('starts disconnected and reports connecting, then connected once bootstrap completes', async () => { + const ctx = setup(); + expect(ctx.session.connectionStatus).toBe('disconnected'); + await ctx.session.connect(); + expect(ctx.onStatus.mock.calls.map((c) => c[0])).toEqual(['connecting']); + autoRespond(ctx.sockets[0]); + ctx.sockets[0].open(); // socket open alone is not 'connected' - bootstrap still has to finish + expect(ctx.session.connectionStatus).toBe('connecting'); + await settle(); + expect(ctx.session.connectionStatus).toBe('connected'); + expect(ctx.onStatus.mock.calls.map((c) => c[0])).toEqual(['connecting', 'connected']); + }); + + it('goes disconnected on an unexpected close, then connecting -> connected on the reconnect', async () => { + const ctx = setup(); + const first = await connected(ctx); + ctx.onStatus.mockClear(); + first.close(1006); + expect(ctx.onStatus.mock.calls.map((c) => c[0])).toEqual(['disconnected']); + await vi.advanceTimersByTimeAsync(1000); + expect(ctx.onStatus.mock.calls.map((c) => c[0])).toEqual(['disconnected', 'connecting']); + autoRespond(ctx.sockets[1]); + ctx.sockets[1].open(); + await settle(); + expect(ctx.session.connectionStatus).toBe('connected'); + }); + + it('is disconnected after a failed ticket fetch, and after stop()', async () => { + const failing = setup({}, vi.fn(async () => new Response('no', { status: 401 })) as unknown as typeof fetch); + await failing.session.connect(); + expect(failing.session.connectionStatus).toBe('disconnected'); + + const ctx = setup(); + await connected(ctx); + ctx.session.stop(); + expect(ctx.session.connectionStatus).toBe('disconnected'); + }); + + it('only notifies on a real change', async () => { + const ctx = setup(); + await connected(ctx); + ctx.onStatus.mockClear(); + ctx.session.stop(); + ctx.session.stop(); + expect(ctx.onStatus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/sonos/constants.ts b/apps/mobile/src/sonos/constants.ts new file mode 100644 index 0000000..5d2114a --- /dev/null +++ b/apps/mobile/src/sonos/constants.ts @@ -0,0 +1,9 @@ +// Mirrors the constants in TrueTunes/src/main.ts. Keep in sync until these move into a shared core package. +export const BASE_URL = 'https://play.sonos.com'; +export const WS_URL = 'wss://api.ws.sonos.com/websocket'; +export const WS_SUBPROTOCOL = 'v1.api.smartspeaker.audio'; +export const SESSION_COOKIE = '__Secure-next-auth.session-token'; + +// Intentional UA spoof — the Sonos BFF rejects non-browser agents. +export const SPOOF_UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'; diff --git a/apps/mobile/src/sonos/operations.ts b/apps/mobile/src/sonos/operations.ts new file mode 100644 index 0000000..061b61b --- /dev/null +++ b/apps/mobile/src/sonos/operations.ts @@ -0,0 +1,71 @@ +// Ported from TrueTunes/src/main.ts (OPERATIONS + platform constants). +// Reverse-engineered BFF paths - see SonosWebPlayerAPI.yaml in the desktop repo. + +export type HttpMethod = 'GET' | 'POST' | 'DELETE' | 'PATCH'; + +export interface Operation { + method: HttpMethod; + path: string; +} + +// YouTube Music service constants used for the nowPlaying fallback lookup. +// Generic Sonos platform IDs, not user-specific. +export const SEARCH_SERVICE_ID = '72711'; +export const SEARCH_ACCOUNT_ID = '13'; + +// Platform-level IDs for queue, favourites and history. Fixed for the platform. +export const PLATFORM_SERVICE_ID = '16751367'; +export const PLATFORM_ACCOUNT_ID = '123209393'; + +const QUEUE = '/api/content/v1/groups/:groupId/services/:platformServiceId/accounts/:platformAccountId/queues/:queueId/resources'; +const V2_ACCOUNT = '/api/content/v2/households/:householdId/services/:serviceId/accounts/:accountId'; +const V1_PLATFORM = '/api/content/v1/households/:householdId/services/:platformServiceId/accounts/:platformAccountId'; + +export const OPERATIONS: Record = { + // Auth + getAuthDiscovery: { method: 'GET', path: '/api/authz/discovery' }, + // Groups + getGroups: { method: 'GET', path: '/api/groups/v1/households/:householdId/groups' }, + // Queue - platform-level service IDs, not the user's integration service ID. + getQueueResources: { method: 'GET', path: QUEUE }, + addQueueResource: { method: 'POST', path: QUEUE }, + deleteQueueResources: { method: 'DELETE', path: QUEUE }, + reorderQueueResources: { method: 'PATCH', path: QUEUE }, + // Browse + browseAlbum: { method: 'GET', path: `${V2_ACCOUNT}/albums/:albumId/browse` }, + browseArtist: { method: 'GET', path: `${V2_ACCOUNT}/artists/:artistId/browse` }, + browseContainer: { method: 'GET', path: `${V2_ACCOUNT}/containers/:containerId/browse` }, + browsePlaylist: { method: 'GET', path: `${V2_ACCOUNT}/playlists/:playlistId/browse` }, + getCatalogContainerResources: { + method: 'GET', + path: '/api/content/v1/households/:householdId/services/:serviceId/accounts/:accountId/catalog/containers/:containerId/resources', + }, + // Favourites & History - platform-level IDs + getFavoriteResources: { method: 'GET', path: `${V1_PLATFORM}/favorites/resources` }, + getHistory: { method: 'GET', path: `${V1_PLATFORM}/history` }, + // Search + searchHousehold: { method: 'GET', path: '/api/content/v1/households/:householdId/search' }, + searchService: { + method: 'GET', + path: '/api/content/v1/households/:householdId/services/:searchServiceId/accounts/:searchAccountId/search', + }, + // Integrations + getIntegrations: { method: 'GET', path: '/api/content/v1/households/:householdId/integrations' }, + getIntegrationRegistrations: { + method: 'GET', + path: '/api/content/v1/households/:householdId/integrations/registrations', + }, + // Now Playing + getTrackNowPlaying: { method: 'GET', path: `${V2_ACCOUNT}/tracks/:trackId/nowplaying` }, + // Playback (BFF paths) + getPlaybackState: { method: 'GET', path: '/api/playback/v1/groups/:groupId' }, + play: { method: 'POST', path: '/api/playback/v1/groups/:groupId/play' }, + pause: { method: 'POST', path: '/api/playback/v1/groups/:groupId/pause' }, + skipToNextTrack: { method: 'POST', path: '/api/playback/v1/groups/:groupId/skipToNextTrack' }, + skipToPreviousTrack: { method: 'POST', path: '/api/playback/v1/groups/:groupId/skipToPreviousTrack' }, + setPlayMode: { method: 'POST', path: '/api/playback/v1/groups/:groupId/playMode' }, + // Platform + getMfe: { method: 'GET', path: '/api/mfe' }, + getOptimizelyConfig: { method: 'GET', path: '/api/optimizely/:key' }, + postMetrics: { method: 'POST', path: '/api/metrics' }, +}; diff --git a/apps/mobile/src/sonos/protocol.ts b/apps/mobile/src/sonos/protocol.ts new file mode 100644 index 0000000..c6df3e6 --- /dev/null +++ b/apps/mobile/src/sonos/protocol.ts @@ -0,0 +1,102 @@ +// Pure helpers ported from TrueTunes/src/main.ts (findTicket, extractDevices). + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Deep-search an arbitrary JSON object for the WebSocket ticket UUID. */ +export function findTicket(obj: unknown, depth = 0): string | null { + if (depth > 10 || obj === null || obj === undefined) return null; + if (Array.isArray(obj)) { + for (const item of obj) { + const hit = findTicket(item, depth + 1); + if (hit) return hit; + } + return null; + } + if (typeof obj === 'object') { + const rec = obj as Record; + // Prefer keys that look like 'ticket' + for (const [key, val] of Object.entries(rec)) { + if (key.toLowerCase().includes('ticket') && typeof val === 'string' && UUID_RE.test(val)) { + return val; + } + } + for (const val of Object.values(rec)) { + const hit = findTicket(val, depth + 1); + if (hit) return hit; + } + } + return null; +} + +interface WsDevice { + info?: { id?: string; name?: string }; + groupId?: string; + isCoordinator?: boolean; +} + +export interface GroupInfo { + /** Full groupId with session (RINCON_xxx:sessionId). */ + id: string; + /** Bare RINCON ID - used as queueId. */ + coordinatorId: string; + /** Room/group name. */ + name: string; + playerIds: string[]; +} + +export function extractDevices(payload: unknown): { + coordinatorGroupIds: string[]; + playerIds: string[]; + groups: GroupInfo[]; +} { + const devices: WsDevice[] = (payload as { devices?: WsDevice[] })?.devices ?? []; + + // groupId -> coordinator + all members + const groupMap = new Map(); + for (const d of devices) { + if (!d.groupId) continue; + if (!groupMap.has(d.groupId)) groupMap.set(d.groupId, { members: [] }); + const g = groupMap.get(d.groupId)!; + g.members.push(d); + if (d.isCoordinator) g.coordinator = d; + } + + const coordinatorGroupIds: string[] = []; + const groups: GroupInfo[] = []; + + for (const [groupId, { coordinator, members }] of groupMap) { + if (!coordinator?.info?.id) continue; + coordinatorGroupIds.push(groupId); + groups.push({ + id: groupId, + coordinatorId: coordinator.info.id, + name: coordinator.info.name ?? coordinator.info.id, + playerIds: members.map((m) => m.info?.id ?? '').filter(Boolean), + }); + } + + // Deduplicate player IDs across all groups + const seen = new Set(); + const playerIds: string[] = []; + for (const d of devices) { + if (d.info?.id && !seen.has(d.info.id)) { + seen.add(d.info.id); + playerIds.push(d.info.id); + } + } + + return { coordinatorGroupIds, playerIds, groups }; +} + +/** Kept for the Phase 0 spike screen. */ +export function extractGroups(payload: unknown): GroupInfo[] { + return extractDevices(payload).groups; +} + +/** RFC4122-ish v4 id; Hermes has no guaranteed crypto.randomUUID. */ +export function newCorrId(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); +} diff --git a/apps/mobile/src/sonos/queueOps.ts b/apps/mobile/src/sonos/queueOps.ts new file mode 100644 index 0000000..80a6e71 --- /dev/null +++ b/apps/mobile/src/sonos/queueOps.ts @@ -0,0 +1,147 @@ +// Ported from TrueTunes/src/main.ts (computeReorderBatches + queue:reorder/remove/clear/setId). +// +// The API only accepts contiguous runs per PATCH (?items=0,1&positions=4,5). A non-contiguous +// selection (e.g. [3,4,7]) is split into runs ([3,4] and [7]) and each run is patched in sequence. +// After each patch the virtual queue changes, so subsequent runs' positions are recalculated +// against the updated state. +// +// Processing runs left-to-right works universally (moving earlier OR later): for each run we find +// its "pivot" - the item that should immediately precede the run in the desired final order - then +// look up that pivot's current position in the working array to derive finalPos. + +import type { SonosClient } from './sonosClient'; +import type { FetchResponse, SonosConfig } from './types'; + +export interface ReorderBatch { + items: number[]; + positions: number[]; +} + +export function computeReorderBatches(fromIndices: number[], insertBefore: number, queueLength: number): ReorderBatch[] { + const sorted = [...fromIndices].sort((a, b) => a - b); + + // Group into contiguous runs (left-to-right) + const runs: number[][] = []; + let curr = [sorted[0]]; + for (let i = 1; i < sorted.length; i++) { + if (sorted[i] === sorted[i - 1] + 1) curr.push(sorted[i]); + else { + runs.push(curr); + curr = [sorted[i]]; + } + } + runs.push(curr); + + // Build the desired target order + const selectedSet = new Set(sorted); + const nonSelected: number[] = []; + for (let i = 0; i < queueLength; i++) if (!selectedSet.has(i)) nonSelected.push(i); + + // Anchor = first non-selected item at-or-after insertBefore + let anchorIdx = nonSelected.length; // default: append at end + for (let i = insertBefore; i < queueLength; i++) { + if (!selectedSet.has(i)) { + anchorIdx = nonSelected.indexOf(i); + break; + } + } + const target = [...nonSelected.slice(0, anchorIdx), ...sorted, ...nonSelected.slice(anchorIdx)]; + + // Early-exit if already in the desired order + if (target.every((v, i) => v === i)) return []; + + const working: number[] = Array.from({ length: queueLength }, (_, i) => i); + const batches: ReorderBatch[] = []; + + for (const run of runs) { + const runLen = run.length; + const currentStart = working.indexOf(run[0]); + + // Final position via the pivot item (what should immediately precede this run) + const targetRunStart = target.indexOf(run[0]); + let finalPos: number; + if (targetRunStart === 0) { + finalPos = 0; + } else { + const pivot = target[targetRunStart - 1]; + finalPos = working.indexOf(pivot) + 1; + } + + if (currentStart === finalPos) continue; // run is already in place + + batches.push({ + items: Array.from({ length: runLen }, (_, i) => currentStart + i), + positions: Array.from({ length: runLen }, (_, i) => finalPos + i), + }); + + // Apply the move to the working array so subsequent runs see the updated state + const runContents = working.slice(currentStart, currentStart + runLen); + const rest = [...working.slice(0, currentStart), ...working.slice(currentStart + runLen)]; + const insertAt = finalPos <= currentStart ? finalPos : finalPos - runLen; + working.splice(0, working.length, ...rest.slice(0, insertAt), ...runContents, ...rest.slice(insertAt)); + } + + return batches; +} + +const isIndexList = (v: unknown): v is number[] => + Array.isArray(v) && v.every((i) => typeof i === 'number' && i >= 0); + +export interface QueueOps { + reorder(fromIndices: number[], toIndex: number, queueLength: number): Promise; + remove(indices: number[]): Promise; + clear(): Promise; + setQueueId(queueId: string): void; +} + +export function createQueueOps(deps: { + client: SonosClient; + config: SonosConfig; + log?: (...args: unknown[]) => void; +}): QueueOps { + const log = deps.log ?? (() => {}); + const { client, config } = deps; + + return { + async reorder(fromIndices, toIndex, queueLength) { + if (!isIndexList(fromIndices)) return { error: 'Invalid fromIndices' }; + if (typeof toIndex !== 'number' || toIndex < 0) return { error: 'Invalid toIndex' }; + if (typeof queueLength !== 'number' || queueLength <= 0) return { error: 'Invalid queueLength' }; + log(`[queue] reorder: [${fromIndices.join(',')}] -> before ${toIndex} (n=${queueLength})`); + if (!fromIndices.length) return { ok: true }; + + const batches = computeReorderBatches(fromIndices, toIndex, queueLength); + log(`[queue] reorder: ${batches.length} batch(es)`, JSON.stringify(batches)); + + for (const batch of batches) { + const result = await client.fetch({ + operationId: 'reorderQueueResources', + query: { items: batch.items.join(','), positions: batch.positions.join(',') }, + }); + if (result.error) { + log('[queue] reorder batch failed:', result.error); + return result; + } + } + return { ok: true }; + }, + + async remove(indices) { + if (!isIndexList(indices)) return { error: 'Invalid indices' }; + log(`[queue] remove: [${indices.join(',')}]`); + return client.fetch({ operationId: 'deleteQueueResources', query: { items: indices.join(',') } }); + }, + + async clear() { + log('[queue] clear all'); + return client.fetch({ operationId: 'deleteQueueResources' }); + }, + + setQueueId(queueId) { + if (typeof queueId === 'string' && queueId) { + config.queueId = queueId; + log(`[queue] queueId synced: ${queueId}`); + } + }, + }; +} diff --git a/apps/mobile/src/sonos/sonosClient.ts b/apps/mobile/src/sonos/sonosClient.ts new file mode 100644 index 0000000..b5f879c --- /dev/null +++ b/apps/mobile/src/sonos/sonosClient.ts @@ -0,0 +1,267 @@ +// Ported from TrueTunes/src/main.ts (buildUrl, sonosFetch, discoverServiceCredentials). +// Platform-neutral: everything environmental is injected so this runs in Node tests and in RN. + +import { BASE_URL, SESSION_COOKIE, SPOOF_UA } from './constants'; +import { + OPERATIONS, + PLATFORM_ACCOUNT_ID, + PLATFORM_SERVICE_ID, + SEARCH_ACCOUNT_ID, + SEARCH_SERVICE_ID, +} from './operations'; +import type { FetchRequest, FetchResponse, SonosConfig } from './types'; + +const TRANSIENT_STATUSES = new Set([500, 502, 503, 504]); +const RETRY_DELAYS_MS = [250, 750]; + +export function buildUrl( + operationPath: string, + config: SonosConfig, + pathParams: Record = {}, + query: Record = {}, +): string { + const defaults: Record = { + searchServiceId: SEARCH_SERVICE_ID, + searchAccountId: SEARCH_ACCOUNT_ID, + platformServiceId: PLATFORM_SERVICE_ID, + platformAccountId: PLATFORM_ACCOUNT_ID, + }; + for (const key of ['householdId', 'serviceId', 'accountId', 'groupId', 'queueId'] as const) { + const value = config[key]; + if (value) defaults[key] = value; + } + const params = { ...defaults, ...pathParams }; + + const urlPath = operationPath.replace(/:([a-zA-Z]+)/g, (_, key: string) => { + if (!(key in params)) throw new Error(`Missing path param: ${key}`); + return encodeURIComponent(params[key]); + }); + + const qs = Object.entries(query) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + + return `${BASE_URL}${urlPath}${qs ? `?${qs}` : ''}`; +} + +/** "+01:00"-style offset for the X-Sonos-Timezone header (desktop hardcodes +01:00). */ +export function timezoneOffset(now = new Date()): string { + const minutes = -now.getTimezoneOffset(); + const sign = minutes >= 0 ? '+' : '-'; + const abs = Math.abs(minutes); + const hh = String(Math.floor(abs / 60)).padStart(2, '0'); + const mm = String(abs % 60).padStart(2, '0'); + return `${sign}${hh}:${mm}`; +} + +interface ServiceCreds { + serviceId: string; + accountId: string; +} + +/** Sonos returns hyphenated keys (service-id, account-id); accept camelCase too. */ +export function parseIntegrationsCreds(data: unknown): ServiceCreds[] { + const items = Array.isArray(data) ? data : data !== null && typeof data === 'object' ? [data] : []; + const results: ServiceCreds[] = []; + for (const item of items) { + if (typeof item !== 'object' || item === null) continue; + const rec = item as Record; + const sid = rec['service-id'] ?? rec['serviceId']; + const aid = rec['account-id'] ?? rec['accountId']; + if ((typeof sid === 'string' || typeof sid === 'number') && (typeof aid === 'string' || typeof aid === 'number')) { + results.push({ serviceId: String(sid), accountId: String(aid) }); + } + } + return results; +} + +export interface SonosClientDeps { + /** Mutable, shared with the WS session; read at call time. */ + config: SonosConfig; + getSessionToken: () => string | null; + /** Called on a 401 after which the caller should clear the token and re-authenticate. */ + onUnauthorized: () => void; + /** Resolves once the WS bootstrap has discovered the householdId (or gives up). Lets early calls wait instead of failing. */ + awaitHousehold?: () => Promise; + /** Called when discovery fills serviceId/accountId so the caller can persist them. */ + onConfigChanged?: () => void; + fetchImpl?: typeof fetch; + log?: (...args: unknown[]) => void; + /** Telemetry hook (api_retry, api_error, auth_expired). */ + onEvent?: (name: string, props?: Record) => void; + sleep?: (ms: number) => Promise; + timezone?: () => string; +} + +export interface SonosClient { + fetch(request: FetchRequest): Promise; + discoverServiceCredentials(): Promise; +} + +export function createSonosClient(deps: SonosClientDeps): SonosClient { + const { config } = deps; + const doFetch = deps.fetchImpl ?? fetch; + const log = deps.log ?? (() => {}); + const emit = deps.onEvent ?? (() => {}); + const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const timezone = deps.timezone ?? timezoneOffset; + + let discoverInFlight: Promise | null = null; + + /** Uses /integrations/registrations (only needs householdId). Coalesces concurrent callers. */ + function discoverServiceCredentials(): Promise { + if (config.serviceId) return Promise.resolve(); + if (discoverInFlight) return discoverInFlight; + discoverInFlight = (async () => { + try { + const r = await sonosFetch({ operationId: 'getIntegrationRegistrations' }); + if (r.error || !r.data) { + log('[config] getIntegrationRegistrations failed:', r.error); + return; + } + // Sonos places the primary content service first (owns queue, favourites, history). + const primary = parseIntegrationsCreds(r.data)[0]; + if (primary) { + config.serviceId = primary.serviceId; + config.accountId = primary.accountId; + deps.onConfigChanged?.(); + log('[config] Using serviceId:', config.serviceId, 'accountId:', config.accountId); + } else { + log('[config] No service credentials found - content APIs will fail until configured'); + } + } finally { + discoverInFlight = null; + } + })(); + return discoverInFlight; + } + + async function sonosFetch(request: FetchRequest): Promise { + const token = deps.getSessionToken(); + if (!token) return { error: 'No session token — please log in first' }; + + const operation = OPERATIONS[request.operationId]; + if (!operation) return { error: `Unknown operation: ${request.operationId}` }; + + let url: string; + try { + url = buildUrl(operation.path, config, request.pathParams, request.query); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Screens can fire before the WS bootstrap has found the household; wait for it, then retry once. + if (msg.includes('householdId') && !config.householdId && deps.awaitHousehold) { + log('[api] householdId missing - waiting for WS bootstrap before retrying'); + await deps.awaitHousehold(); + // Timed out (never bootstrapped): report the original error rather than waiting again. + if (!config.householdId) return { error: msg }; + return sonosFetch(request); + } + // If the only missing param is serviceId, attempt on-demand discovery and retry once. + if (msg.includes('serviceId') && !config.serviceId) { + log('[api] serviceId missing - attempting on-demand discovery before retrying'); + await discoverServiceCredentials(); + try { + url = buildUrl(operation.path, config, request.pathParams, request.query); + } catch (retryErr) { + return { error: retryErr instanceof Error ? retryErr.message : String(retryErr) }; + } + } else { + return { error: msg }; + } + } + + const headers: Record = { + Cookie: `${SESSION_COOKIE}=${token}`, + 'X-Sonos-Timezone': timezone(), + 'X-Sonos-Debug': 'use-section-text=true', + Accept: '*/*', + Referer: `${BASE_URL}/en-us/web-app`, + 'User-Agent': SPOOF_UA, + ...(request.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + ...request.headers, + }; + + // Retry transient failures for idempotent methods only: POST/PATCH/DELETE can have side + // effects, and queue mutations use If-Match etags that don't survive a blind replay. + const isIdempotent = operation.method === 'GET'; + + let response!: Response; + let bodyText = ''; + const resHeaders: Record = {}; + + for (let attempt = 0; ; attempt++) { + log(`[api] ${operation.method} ${url}${attempt > 0 ? ` (retry ${attempt})` : ''}`); + + let networkError: string | null = null; + let attemptResponse: Response | null = null; + let attemptBody = ''; + + try { + attemptResponse = await doFetch(url, { + method: operation.method, + headers, + ...(request.body !== undefined ? { body: JSON.stringify(request.body) } : {}), + }); + } catch (err) { + networkError = `Fetch failed: ${err instanceof Error ? err.message : String(err)}`; + } + + if (attemptResponse) { + try { + attemptBody = await attemptResponse.text(); + } catch { + /* ignore */ + } + } + + const isTransient = + networkError !== null || (attemptResponse !== null && TRANSIENT_STATUSES.has(attemptResponse.status)); + + if (isTransient && isIdempotent && attempt < RETRY_DELAYS_MS.length) { + emit('api_retry', { + operationId: request.operationId, + attempt: attempt + 1, + statusCode: attemptResponse ? attemptResponse.status : 0, + }); + await sleep(RETRY_DELAYS_MS[attempt]); + continue; + } + + if (networkError) return { error: networkError }; + + response = attemptResponse!; + bodyText = attemptBody; + response.headers.forEach((val, key) => { + resHeaders[key] = val; + }); + break; + } + + if (response.status === 401) { + log('[api] 401 - auth expired'); + emit('auth_expired', { operationId: request.operationId }); + deps.onUnauthorized(); + return { error: '401 Unauthorized — re-authenticating' }; + } + + if (!response.ok) { + log(`[api] ${response.status} ${response.statusText} - ${url}\n${bodyText.slice(0, 500)}`); + emit('api_error', { operationId: request.operationId, statusCode: response.status }); + return { + error: `${response.status} ${response.statusText}${bodyText ? `: ${bodyText.slice(0, 200)}` : ''}`, + }; + } + + const etag = resHeaders['etag']; + if (!bodyText) return { data: null, ...(etag ? { etag } : {}) }; + + try { + return { data: JSON.parse(bodyText), ...(etag ? { etag } : {}) }; + } catch { + return { error: `Response is not JSON (${response.status} ${response.statusText})` }; + } + } + + return { fetch: sonosFetch, discoverServiceCredentials }; +} diff --git a/apps/mobile/src/sonos/types.ts b/apps/mobile/src/sonos/types.ts new file mode 100644 index 0000000..d741836 --- /dev/null +++ b/apps/mobile/src/sonos/types.ts @@ -0,0 +1,28 @@ +// Mirrors TrueTunes/src/types.ts (Sonos fetch types). Keep in sync with the desktop app. + +export interface FetchRequest { + operationId: string; + pathParams?: Record; + query?: Record; + body?: unknown; + headers?: Record; +} + +export interface FetchResponse { + data?: unknown; + error?: string; + etag?: string; +} + +/** Runtime identifiers the URL builder fills into `:param` placeholders. */ +export interface SonosConfig { + /** Primary music service ID, discovered from integration registrations. */ + serviceId?: string; + accountId?: string; + /** Re-discovered on each WS bootstrap. */ + householdId?: string; + /** Session-scoped (RINCON_xxx:sessionId), re-discovered on each WS bootstrap. */ + groupId?: string; + /** Coordinator player ID. */ + queueId?: string; +} diff --git a/apps/mobile/src/sonos/wsClient.ts b/apps/mobile/src/sonos/wsClient.ts new file mode 100644 index 0000000..e044ffe --- /dev/null +++ b/apps/mobile/src/sonos/wsClient.ts @@ -0,0 +1,101 @@ +import { BASE_URL, SPOOF_UA, WS_SUBPROTOCOL, WS_URL } from './constants'; +import { newCorrId } from './protocol'; + +type Frame = [Record, unknown]; + +// React Native's WebSocket accepts a third `options.headers` argument that lib.dom types omit. +const HeaderWebSocket = WebSocket as unknown as new ( + url: string, + protocols: string[], + options: { headers: Record }, +) => WebSocket; + +/** Minimal Sonos WS client: corrId request/response plus one-shot push waiters. */ +export class SonosWs { + private ws: WebSocket | null = null; + private pending = new Map void>(); + private pushOnce = new Map void>(); + + constructor(private onFrame: (header: Record, payload: unknown) => void) {} + + get isOpen(): boolean { + return this.ws?.readyState === WebSocket.OPEN; + } + + connect(ticket: string, log: (msg: string) => void): Promise { + return new Promise((resolve, reject) => { + const ws = new HeaderWebSocket(`${WS_URL}?ticket=${ticket}`, [WS_SUBPROTOCOL], { + headers: { Origin: BASE_URL, 'User-Agent': SPOOF_UA }, + }); + this.ws = ws; + ws.onopen = () => { + log(`ws open, negotiated protocol="${ws.protocol}"`); + resolve(); + }; + ws.onerror = (e) => { + const msg = (e as { message?: string }).message ?? 'unknown'; + log(`ws error: ${msg}`); + reject(new Error(msg)); + }; + ws.onclose = (e) => log(`ws closed code=${e.code} reason="${e.reason}"`); + ws.onmessage = (e) => this.handle(String(e.data)); + }); + } + + private handle(raw: string): void { + let frame: Frame; + try { + frame = JSON.parse(raw) as Frame; + } catch { + return; + } + const [header, payload] = frame; + const corrId = header['corrId'] as string | undefined; + if (corrId && this.pending.has(corrId)) { + this.pending.get(corrId)!(payload); + this.pending.delete(corrId); + } + const ns = header['namespace'] as string | undefined; + const name = (header['name'] ?? header['type']) as string | undefined; + if (ns && name) { + const key = `${ns}:${name}`; + const waiter = this.pushOnce.get(key); + if (waiter) { + waiter(payload); + this.pushOnce.delete(key); + } + } + this.onFrame(header, payload); + } + + send(header: Record, payload: Record, timeoutMs = 10_000): Promise { + return new Promise((resolve, reject) => { + if (!this.isOpen) return reject(new Error('WebSocket not open')); + const corrId = newCorrId(); + const timer = setTimeout(() => { + this.pending.delete(corrId); + reject(new Error(`WS timeout: ${JSON.stringify(header)}`)); + }, timeoutMs); + this.pending.set(corrId, (p) => { + clearTimeout(timer); + resolve(p); + }); + this.ws!.send(JSON.stringify([{ ...header, corrId }, payload])); + }); + } + + waitForPush(key: string, timeoutMs = 10_000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`push timeout: ${key}`)), timeoutMs); + this.pushOnce.set(key, (p) => { + clearTimeout(timer); + resolve(p); + }); + }); + } + + close(): void { + this.ws?.close(1000); + this.ws = null; + } +} diff --git a/apps/mobile/src/sonos/wsSession.ts b/apps/mobile/src/sonos/wsSession.ts new file mode 100644 index 0000000..0e882b5 --- /dev/null +++ b/apps/mobile/src/sonos/wsSession.ts @@ -0,0 +1,497 @@ +// Ported from TrueTunes/src/main.ts (WebSocket section: wsSend, handleWsMessage, runBootstrap, +// connectWebSocket, scheduleReconnect, group/transport IPC handlers). Platform-neutral: the socket, +// timers and renderer broadcast are injected so it runs in Node tests and in React Native. + +import { BASE_URL, SESSION_COOKIE, SPOOF_UA, WS_SUBPROTOCOL, WS_URL } from './constants'; +import { extractDevices, findTicket, newCorrId } from './protocol'; +import type { GroupInfo } from './protocol'; +import type { SonosConfig } from './types'; + +const WS_OPEN = 1; +const SEND_TIMEOUT_MS = 10_000; + +/** The subset of the browser/RN WebSocket API the session needs. */ +export interface SocketLike { + readyState: number; + send(data: string): void; + close(code?: number): void; + onopen: (() => void) | null; + onmessage: ((e: { data: unknown }) => void) | null; + onerror: ((e: { message?: string }) => void) | null; + onclose: ((e: { code: number }) => void) | null; +} + +export type SocketFactory = (url: string, protocols: string[], headers: Record) => SocketLike; + +export type WsConfig = SonosConfig & { + /** Stable coordinator RINCON ID for the preferred group - persisted across sessions. */ + preferredCoordinatorId?: string; +}; + +/** Coarse connection state for the UI. 'connecting' covers ticket fetch, socket open and bootstrap. */ +export type WsStatus = 'connecting' | 'connected' | 'disconnected'; + +/** Channels mirror the Electron ipcRenderer events so the bridge stays 1:1 with desktop. */ +export type WsBroadcast = + | { channel: 'ws:ready' } + | { channel: 'ws:groups'; groups: GroupInfo[] } + | { channel: 'ws:message'; header: Record; payload: unknown }; + +export interface WsSessionDeps { + config: WsConfig; + getSessionToken: () => string | null; + createSocket: SocketFactory; + discoverServiceCredentials: () => Promise; + broadcast: (msg: WsBroadcast) => void; + /** Persist config (preferredCoordinatorId changed). */ + onConfigChanged?: () => void; + onPlaybackState?: (isPlaying: boolean) => void; + onStatus?: (status: WsStatus) => void; + fetchImpl?: typeof fetch; + log?: (...args: unknown[]) => void; + onEvent?: (name: string, props?: Record) => void; + onException?: (err: unknown, props?: Record) => void; +} + +type NotConnected = { error: 'WS not connected' }; +const NOT_CONNECTED: NotConnected = { error: 'WS not connected' }; + +export class WsSession { + private ws: SocketLike | null = null; + private reconnectDelay = 1000; + private reconnectTimer: ReturnType | null = null; + private quitting = false; + private discoveredGroups: GroupInfo[] = []; + + // corrId -> resolver for command responses + private pending = new Map void>(); + // 'namespace:name' -> one-shot resolver for the first matching push + private pushOnce = new Map void>(); + + private readonly config: WsConfig; + private readonly doFetch: typeof fetch; + private readonly log: (...args: unknown[]) => void; + private readonly emit: (name: string, props?: Record) => void; + + constructor(private readonly deps: WsSessionDeps) { + this.config = deps.config; + this.doFetch = deps.fetchImpl ?? fetch; + this.log = deps.log ?? (() => {}); + this.emit = deps.onEvent ?? (() => {}); + } + + private householdWaiters: Array<() => void> = []; + private status: WsStatus = 'disconnected'; + + private setStatus(next: WsStatus): void { + if (next === this.status) return; + this.status = next; + this.deps.onStatus?.(next); + } + + get connectionStatus(): WsStatus { + return this.status; + } + + /** + * Resolves once the bootstrap has discovered the householdId, or after `timeoutMs` if it never + * does. Never rejects - callers re-check config.householdId. + */ + whenHousehold(timeoutMs = 15_000): Promise { + if (this.config.householdId) return Promise.resolve(); + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + this.householdWaiters = this.householdWaiters.filter((w) => w !== done); + resolve(); + }, timeoutMs); + this.householdWaiters.push(done); + }); + } + + get groups(): GroupInfo[] { + return this.discoveredGroups; + } + + get isOpen(): boolean { + return this.ws?.readyState === WS_OPEN; + } + + // ─── Messaging ───────────────────────────────────────────────────────────── + + send(header: Record, payload: Record): Promise { + return new Promise((resolve, reject) => { + const ws = this.ws; + if (!ws || ws.readyState !== WS_OPEN) { + reject(new Error('WebSocket not open')); + return; + } + const corrId = newCorrId(); + const timer = setTimeout(() => { + this.pending.delete(corrId); + this.emit('ws_timeout', { + namespace: String(header['namespace'] ?? ''), + command: String(header['command'] ?? ''), + }); + reject(new Error(`WS timeout: ${JSON.stringify(header)}`)); + }, SEND_TIMEOUT_MS); + this.pending.set(corrId, (p) => { + clearTimeout(timer); + resolve(p); + }); + ws.send(JSON.stringify([{ ...header, corrId }, payload])); + }); + } + + private handleMessage(raw: unknown): void { + let parsed: [Record, unknown]; + try { + parsed = JSON.parse(String(raw)) as [Record, unknown]; + } catch { + return; + } + const [header, payload] = parsed; + + // Resolve a pending command response + const corrId = header['corrId'] as string | undefined; + if (corrId && this.pending.has(corrId)) { + this.pending.get(corrId)!(payload); + this.pending.delete(corrId); + } + + // Fire one-shot push listener + const ns = header['namespace'] as string | undefined; + const name = (header['name'] ?? header['type']) as string | undefined; + if (ns && name) { + const key = `${ns}:${name}`; + const waiter = this.pushOnce.get(key); + if (waiter) { + waiter(payload); + this.pushOnce.delete(key); + } + } + + // For playbackExtended and groupVolume only forward the active group - during bootstrap every + // subscribed group pushes a status immediately, and the UI updates state unconditionally on + // these events, so other groups' pushes would corrupt the active group's display. + const msgGroupId = header['groupId'] as string | undefined; + if ( + (ns === 'playbackExtended' || ns === 'groupVolume') && + msgGroupId && + this.config.groupId && + msgGroupId !== this.config.groupId + ) { + return; + } + + if (ns === 'playbackExtended') { + const state = (payload as Record | null)?.['playback'] as Record | undefined; + if (state?.['playbackState']) { + this.deps.onPlaybackState?.(state['playbackState'] === 'PLAYBACK_STATE_PLAYING'); + } + } + + this.deps.broadcast({ channel: 'ws:message', header, payload }); + } + + // ─── Bootstrap ───────────────────────────────────────────────────────────── + + private async runBootstrap(): Promise { + this.log('[ws] Bootstrap start'); + const { config } = this; + + // 1 - households (confirms connection, gives live householdId) + const householdsResp = await this.send( + { command: 'getHouseholds', namespace: 'households' }, + { connectedOnly: 'true' }, + ); + const households = (householdsResp as { households?: Array<{ id: string }> })?.households ?? []; + const householdId = households[0]?.id ?? config.householdId; + config.householdId = householdId; + this.log('[ws] Household:', householdId); + if (householdId) { + const waiters = this.householdWaiters; + this.householdWaiters = []; + waiters.forEach((w) => w()); + } + + // 1b - discover serviceId/accountId if not already known + await this.deps.discoverServiceCredentials(); + + // 2 - subscribe to devices; the immediate push carries the full topology + const devicesPayload = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('devices push timeout')), SEND_TIMEOUT_MS); + this.pushOnce.set('devices:devicesStatus', (p) => { + clearTimeout(timer); + resolve(p); + }); + this.send({ namespace: 'devices', householdId, command: 'subscribe' }, {}).catch(reject); + }); + + const { coordinatorGroupIds, playerIds, groups } = extractDevices(devicesPayload); + this.log(`[ws] ${coordinatorGroupIds.length} coordinator group(s), ${playerIds.length} player(s)`); + + // Restore the saved group preference (matched by stable coordinatorId) if it exists in the + // current household; otherwise fall back to the first group and persist that. + this.discoveredGroups = groups; + if (groups.length > 0) { + const saved = config.preferredCoordinatorId + ? groups.find((g) => g.coordinatorId === config.preferredCoordinatorId) + : null; + const active = saved ?? groups[0]; + config.groupId = active.id; + // The queue ID is the coordinator's player ID. Desktop leaves this to the renderer's setQueueId, + // which races with the first queue call; setting it here removes that gap. + config.queueId = active.coordinatorId; + if (!saved) { + config.preferredCoordinatorId = active.coordinatorId; + this.deps.onConfigChanged?.(); + } + this.log(`[ws] Active group: "${active.name}" (${active.id})`); + } + + // 3 - playbackExtended first so we can capture initial state + const pbSubResults = await Promise.all( + coordinatorGroupIds.map((groupId) => + this.send({ namespace: 'playbackExtended', groupId, command: 'subscribe' }, {}) + .then((resp) => ({ groupId, resp })) + .catch(() => null), + ), + ); + + // 4 - groupVolume (capture responses to seed the initial volume) and player-level hardware + const [volSubResults] = await Promise.all([ + Promise.all( + coordinatorGroupIds.map((groupId) => + this.send({ namespace: 'groupVolume', groupId, command: 'subscribe' }, {}) + .then((resp) => ({ groupId, resp })) + .catch(() => null), + ), + ), + ...playerIds.flatMap((playerId) => [ + this.send({ namespace: 'playerVolume', playerId, command: 'subscribe' }, {}), + this.send({ namespace: 'hardwareStatus', playerId, command: 'subscribe' }, {}), + ]), + ]); + + this.deps.broadcast({ channel: 'ws:ready' }); + this.deps.broadcast({ channel: 'ws:groups', groups }); + + // Subscribe responses arrive before any UI listener exists, so re-broadcast the active + // group's initial playback and volume as ordinary pushes. + const activePb = pbSubResults.find((r) => r?.groupId === config.groupId); + if (activePb?.resp) { + this.deps.broadcast({ + channel: 'ws:message', + header: { namespace: 'playbackExtended', groupId: activePb.groupId }, + payload: activePb.resp, + }); + } + const activeVol = volSubResults.find((r) => r?.groupId === config.groupId); + if (activeVol?.resp) { + this.deps.broadcast({ + channel: 'ws:message', + header: { namespace: 'groupVolume', groupId: activeVol.groupId }, + payload: activeVol.resp, + }); + } + + this.log('[ws] Bootstrap complete'); + } + + // ─── Lifecycle ───────────────────────────────────────────────────────────── + + private async fetchMfeData(): Promise { + const token = this.deps.getSessionToken(); + if (!token) throw new Error('No session token'); + const res = await this.doFetch(`${BASE_URL}/api/mfe`, { + headers: { + Cookie: `${SESSION_COOKIE}=${token}`, + 'User-Agent': SPOOF_UA, + Accept: '*/*', + Referer: `${BASE_URL}/en-us/web-app`, + }, + }); + if (!res.ok) throw new Error(`MFE fetch ${res.status}`); + return res.json(); + } + + private scheduleReconnect(): void { + if (this.quitting) return; + if (this.reconnectTimer) return; // already pending + this.log(`[ws] Reconnecting in ${this.reconnectDelay / 1000}s`); + this.reconnectTimer = setTimeout(async () => { + this.reconnectTimer = null; + this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000); + await this.connect(); + }, this.reconnectDelay); + } + + /** Fetch a fresh ticket, open the socket and run the bootstrap. Safe to call repeatedly. */ + async connect(): Promise { + this.quitting = false; + // Guard against double-connect + if (this.ws && this.ws.readyState <= WS_OPEN) return; + this.setStatus('connecting'); + + let ticket: string | null = null; + try { + ticket = findTicket(await this.fetchMfeData()); + } catch (err) { + this.log('[ws] Ticket fetch failed:', err instanceof Error ? err.message : err); + this.deps.onException?.(err, { phase: 'ws_ticket_fetch' }); + this.setStatus('disconnected'); + this.scheduleReconnect(); + return; + } + + if (!ticket) { + this.log('[ws] No ticket found in MFE response'); + this.emit('ws_ticket_missing'); + this.setStatus('disconnected'); + this.scheduleReconnect(); + return; + } + + this.log('[ws] Connecting, ticket:', ticket.slice(0, 8) + '...'); + + const ws = this.deps.createSocket(`${WS_URL}?ticket=${ticket}`, [WS_SUBPROTOCOL], { + Origin: BASE_URL, + 'User-Agent': SPOOF_UA, + }); + this.ws = ws; + + ws.onopen = () => { + this.log('[ws] Connected'); + this.reconnectDelay = 1000; // reset backoff on successful connect + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + const started = Date.now(); + this.runBootstrap() + .then(() => { + this.setStatus('connected'); + this.emit('ws_connected', { bootstrapMs: Date.now() - started, groupCount: this.discoveredGroups.length }); + }) + .catch((err) => { + this.log('[ws] Bootstrap error:', err instanceof Error ? err.message : err); + this.deps.onException?.(err, { phase: 'ws_bootstrap' }); + ws.close(); + }); + }; + + ws.onmessage = (e) => this.handleMessage(e.data); + + ws.onerror = (e) => { + this.log('[ws] Error:', e.message); + this.deps.onException?.(new Error(e.message ?? 'socket error'), { phase: 'ws_socket' }); + }; + + ws.onclose = (e) => { + this.log(`[ws] Closed (${e.code})`); + if (e.code !== 1000 && e.code !== 1001) this.emit('ws_closed', { code: e.code }); + // A late close from a socket we already replaced must not clobber the new one. + if (this.ws === ws) { + this.ws = null; + this.setStatus('disconnected'); + } + this.scheduleReconnect(); + }; + } + + /** Stop reconnecting and close the socket (app teardown / sign-out). */ + stop(): void { + this.quitting = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.ws?.close(1000); + this.ws = null; + this.setStatus('disconnected'); + } + + /** Re-push ready/groups and current playback so a freshly mounted UI catches up. */ + resync(): void { + if (!this.isOpen) { + // WS is down - attempt a fresh connect (re-runs bootstrap when open) + this.connect().catch((e) => this.log('[ws] resync connect failed', e)); + return; + } + if (this.discoveredGroups.length === 0) return; // bootstrap hasn't completed yet + this.deps.broadcast({ channel: 'ws:ready' }); + this.deps.broadcast({ channel: 'ws:groups', groups: this.discoveredGroups }); + // Re-subscribing makes Sonos push a fresh playbackExtended status + this.refreshPlayback(); + } + + // ─── Commands (mirror the ipcMain handlers) ──────────────────────────────── + + private command(namespace: string, command: string, payload: Record): Promise | NotConnected { + if (!this.isOpen) return NOT_CONNECTED; + return this.send({ namespace, groupId: this.config.groupId, command }, payload); + } + + play() { + return this.command('playback', 'play', { allowTvPauseRestore: true, deviceFeedback: 'NONE' }); + } + pause() { + return this.command('playback', 'pause', { allowTvPauseRestore: true, deviceFeedback: 'NONE' }); + } + skipNext() { + return this.command('playback', 'skipToNextTrack', {}); + } + skipPrev() { + return this.command('playback', 'skipBack', {}); + } + skipToTrack(trackNumber: number) { + return this.command('playback', 'skipToTrack', { trackNumber }); + } + seek(positionMillis: number) { + return this.command('playback', 'seek', { positionMillis }); + } + loadContent(payload: Record) { + return this.command('playback', 'loadContent', payload); + } + setPlayModes(playModes: Record) { + return this.command('playback', 'setPlayModes', { playModes }); + } + setGroupVolume(volume: number) { + // Number.isFinite also rejects NaN, which desktop's typeof check lets through. + if (!Number.isFinite(volume) || volume < 0 || volume > 100) return { error: 'Invalid volume' }; + if (!this.config.groupId) return { error: 'No active group' }; + return this.command('groupVolume', 'setVolume', { volume }); + } + + /** Re-subscribing triggers Sonos to push the current playbackExtended state immediately. */ + refreshPlayback(): void { + if (!this.isOpen) return; + this.send({ namespace: 'playbackExtended', groupId: this.config.groupId, command: 'subscribe' }, {}).catch(() => {}); + } + + setGroup(groupId: string): { ok: true } | { error: string } { + const group = this.discoveredGroups.find((g) => g.id === groupId); + if (!group) return { error: `Unknown group: ${groupId}` }; + this.config.groupId = group.id; + this.config.queueId = group.coordinatorId; + this.config.preferredCoordinatorId = group.coordinatorId; + this.log(`[group] Switched to "${group.name}" - groupId: ${group.id}`); + this.deps.onConfigChanged?.(); + if (this.isOpen) { + // Re-subscribe so the UI immediately gets the new group's now-playing state and volume + // rather than the previous group's last-known values. + this.send({ namespace: 'playbackExtended', groupId: group.id, command: 'subscribe' }, {}).catch(() => {}); + this.send({ namespace: 'groupVolume', groupId: group.id, command: 'subscribe' }, {}).catch(() => {}); + } + return { ok: true }; + } + + /** ipc 'group:getActive' - the persisted, stable preference (not the session-scoped groupId). */ + getActiveGroup(): string | null { + return this.config.preferredCoordinatorId ?? null; + } +} diff --git a/apps/mobile/src/spike/SpikeScreen.tsx b/apps/mobile/src/spike/SpikeScreen.tsx new file mode 100644 index 0000000..e617fe1 --- /dev/null +++ b/apps/mobile/src/spike/SpikeScreen.tsx @@ -0,0 +1,214 @@ +import CookieManager from '@preeternal/react-native-cookie-manager'; +import * as SecureStore from 'expo-secure-store'; +import { useCallback, useRef, useState } from 'react'; +import { Pressable, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, View } from 'react-native'; +import { WebView } from 'react-native-webview'; +import { BASE_URL, SESSION_COOKIE, SPOOF_UA } from '../sonos/constants'; +import { extractGroups, findTicket } from '../sonos/protocol'; +import { createSonosClient } from '../sonos/sonosClient'; +import type { SonosConfig } from '../sonos/types'; +import { SonosWs } from '../sonos/wsClient'; + +const TOKEN_KEY = 'sonos.sessionToken'; +const mask = (t: string) => `${t.slice(0, 8)}...(${t.length} chars)`; + +/** + * Phase 0 go/no-go spike. One button per link in the chain so a failure pinpoints + * exactly where a phone can't do what the Electron main process does. + */ +export function SpikeScreen() { + const [lines, setLines] = useState([]); + const [showLogin, setShowLogin] = useState(false); + const token = useRef(null); + const ticketRef = useRef(null); + const groupId = useRef(null); + const wsRef = useRef(null); + const config = useRef({}).current; + + const log = useCallback((msg: string) => { + console.log('[spike]', msg); + const stamp = new Date().toLocaleTimeString(); + setLines((prev) => [...prev.slice(-200), `${stamp} ${msg}`]); + }, []); + + const run = (name: string, fn: () => Promise) => async () => { + log(`> ${name}`); + try { + await fn(); + } catch (e) { + log(`FAIL ${name}: ${e instanceof Error ? e.message : String(e)}`); + } + }; + + // Step 2: HttpOnly cookie via the native CookieManager. + const readCookie = run('read cookie', async () => { + const cookies = await CookieManager.get(BASE_URL); + log(`cookies on ${BASE_URL}: ${Object.keys(cookies).join(', ') || '(none)'}`); + const c = cookies[SESSION_COOKIE]; + if (!c) throw new Error(`${SESSION_COOKIE} not visible - sign in first, or HttpOnly cookies are hidden`); + token.current = c.value; + await SecureStore.setItemAsync(TOKEN_KEY, c.value); + log(`OK session token ${mask(c.value)}, expires=${c.expires ?? 'session'}; saved to SecureStore`); + }); + + const restore = run('restore token from SecureStore', async () => { + const t = await SecureStore.getItemAsync(TOKEN_KEY); + if (!t) throw new Error('nothing stored'); + token.current = t; + log(`OK restored ${mask(t)} (proves persistence if you killed the app first)`); + }); + + // Step 3: manual Cookie + User-Agent headers on RN fetch. + const fetchMfe = run('GET /api/mfe', async () => { + if (!token.current) throw new Error('no token'); + const res = await fetch(`${BASE_URL}/api/mfe`, { + headers: { + Cookie: `${SESSION_COOKIE}=${token.current}`, + 'User-Agent': SPOOF_UA, + Accept: '*/*', + Referer: `${BASE_URL}/en-us/web-app`, + }, + }); + log(`status ${res.status}`); + if (!res.ok) throw new Error(`MFE fetch ${res.status}`); + const ticket = findTicket(await res.json()); + if (!ticket) throw new Error('no ticket in MFE response'); + ticketRef.current = ticket; + log(`OK ticket ${ticket.slice(0, 8)}...`); + }); + + // Steps 4+5: WS with custom Origin, then the same bootstrap the desktop runs. + const connectWs = run('WS connect + bootstrap', async () => { + if (!ticketRef.current) throw new Error('no ticket - fetch /api/mfe first'); + wsRef.current?.close(); + const ws = new SonosWs((h, p) => { + if (h['namespace'] === 'playbackExtended') { + const state = (p as { playback?: { playbackState?: string } } | null)?.playback?.playbackState; + if (state) log(`push playbackExtended: ${state}`); + } + }); + wsRef.current = ws; + await ws.connect(ticketRef.current, log); + + const households = (await ws.send( + { command: 'getHouseholds', namespace: 'households' }, + { connectedOnly: 'true' }, + )) as { households?: Array<{ id: string }> }; + const householdId = households.households?.[0]?.id; + if (!householdId) throw new Error('no household'); + log(`household ${householdId}`); + config.householdId = householdId; + + const devicesPush = ws.waitForPush('devices:devicesStatus'); + await ws.send({ namespace: 'devices', householdId, command: 'subscribe' }, {}); + const groups = extractGroups(await devicesPush); + if (!groups.length) throw new Error('no groups'); + groups.forEach((g) => log(` group "${g.name}" ${g.id}`)); + groupId.current = groups[0].id; + config.groupId = groups[0].id; + config.queueId = groups[0].coordinatorId; + + await ws.send({ namespace: 'playbackExtended', groupId: groupId.current, command: 'subscribe' }, {}); + log(`OK bootstrapped, active group "${groups[0].name}"`); + }); + + // Step 6: the ported client (src/sonos/sonosClient.ts) against real endpoints. + const clientCalls = run('ported client: real API calls', async () => { + const client = createSonosClient({ + config, + getSessionToken: () => token.current, + onUnauthorized: () => log('client: 401 (would re-authenticate)'), + onEvent: (name, props) => log(`client event ${name} ${JSON.stringify(props ?? {})}`), + }); + const summarize = (label: string, r: { data?: unknown; error?: string }) => { + if (r.error) return log(` ${label}: ERROR ${r.error}`); + const json = JSON.stringify(r.data) ?? 'null'; + log(` ${label}: ok, ${json.length} bytes, keys=${Object.keys((r.data as object) ?? {}).slice(0, 6).join(',')}`); + }; + summarize('getGroups', await client.fetch({ operationId: 'getGroups' })); + await client.discoverServiceCredentials(); + log(` discovered serviceId=${config.serviceId} accountId=${config.accountId}`); + summarize('getQueueResources', await client.fetch({ operationId: 'getQueueResources', query: { count: '5' } })); + summarize('getFavoriteResources', await client.fetch({ operationId: 'getFavoriteResources', query: { count: '5' } })); + summarize('searchHousehold', await client.fetch({ operationId: 'searchHousehold', query: { query: 'adam ant' } })); + }); + + const transport = (command: 'play' | 'pause') => + run(command, async () => { + if (!wsRef.current?.isOpen || !groupId.current) throw new Error('not connected'); + await wsRef.current.send( + { namespace: 'playback', groupId: groupId.current, command }, + { allowTvPauseRestore: true, deviceFeedback: 'NONE' }, + ); + log(`OK ${command} acknowledged`); + }); + + const clearAll = run('clear cookies + stored token', async () => { + await CookieManager.clearAll(); + await SecureStore.deleteItemAsync(TOKEN_KEY); + token.current = null; + log('cleared'); + }); + + if (showLogin) { + return ( + + setShowLogin(false)}> + Done signing in + + log(`nav: ${s.url}`)} + onError={(e) => log(`webview error: ${e.nativeEvent.description}`)} + onHttpError={(e) => log(`webview http ${e.nativeEvent.statusCode}: ${e.nativeEvent.url}`)} + /> + + ); + } + + return ( + + Phase 0 spike + + setShowLogin(true)} /> + + + + + + + + + + + {lines.map((l, i) => ( + + {l} + + ))} + + + ); +} + +function Btn({ label, onPress }: { label: string; onPress: () => void }) { + return ( + + {label} + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: '#111', paddingTop: (StatusBar.currentHeight ?? 24) + 8 }, + title: { color: '#fff', fontSize: 18, fontWeight: '700', paddingHorizontal: 12, paddingBottom: 8 }, + buttons: { paddingHorizontal: 12, gap: 6 }, + btn: { backgroundColor: '#2a6df4', padding: 10, borderRadius: 6 }, + btnText: { color: '#fff', fontWeight: '600' }, + log: { flex: 1, margin: 12, padding: 8, backgroundColor: '#000', borderRadius: 6 }, + logLine: { color: '#9f9', fontFamily: 'monospace', fontSize: 11, marginBottom: 2 }, +}); diff --git a/apps/mobile/src/types/globals.d.ts b/apps/mobile/src/types/globals.d.ts new file mode 100644 index 0000000..211b6cc --- /dev/null +++ b/apps/mobile/src/types/globals.d.ts @@ -0,0 +1,474 @@ +// Ambient declarations for contextBridge APIs injected by preload.ts + +type GeniusDomNode = string | { tag: string; children?: GeniusDomNode[]; attributes?: Record }; + +interface GeniusArtistInfo { + description: GeniusDomNode | null; + alternateNames: string[]; + imageUrl: string | null; + headerImageUrl: string | null; + instagram: string | null; + twitter: string | null; +} + +interface AttributionEntry { + user: string; + timestamp: number; + trackName: string; + artist: string; +} + +type AttributionMap = Record; + +interface AttributionEvent { + type: 'queued'; + eventType?: 'track' | 'album'; + user: string; + uri: string; + trackName: string; + artist: string; + timestamp: number; +} + +interface FetchRequest { + operationId: string; + pathParams?: Record; + query?: Record; + body?: unknown; + headers?: Record; +} + +interface FetchResponse { + data?: unknown; + error?: string; + etag?: string; +} + +type Unsubscribe = () => void; + +interface StatsUser { + userId: string; + count: number; +} +interface StatsTrack { + trackName: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + album?: string; + albumId?: string; + imageUrl?: string; + uri?: string; + count: number; +} +interface StatsArtist { + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + imageUrl?: string; + count: number; +} +interface StatsAlbum { + album: string; + albumId?: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + imageUrl?: string; + count: number; +} +interface StatsResult { + topUsers: StatsUser[]; + topTracks: StatsTrack[]; + topArtists: StatsArtist[]; + topAlbums: StatsAlbum[]; + totalEvents: number; + periodStart: number; + error?: string; +} + +type GameItemCategory = 'track' | 'artist' | 'album'; + +interface GameItem { + category: GameItemCategory; + id: string; + name: string; + subtitle: string; + imageUrl?: string; + uri?: string; + count: number; + topQueuer: string; + queuerCandidates: string[]; + artistKey?: string; + albumKey?: string; +} + +interface GameQuestion { + index: number; + left: GameItem; + right: GameItem; + winner: 'left' | 'right'; + carryover?: 'left' | 'right'; + bonusItem?: 'left' | 'right'; +} + +interface GameDoc { + id: string; + status: 'generating' | 'ready'; + generatedAt: number; + lowData: boolean; + questions: GameQuestion[]; +} + +interface GamePending { + status: 'pending'; + gameId?: string; +} + +type GameFetchResult = GameDoc | GamePending | { error: string }; + +interface GameScoreDoc { + id: string; + gameId: string; + userName: string; + mainScore: number; + bonusScore: number; + total: number; + completedAt: number; +} + +interface GameLeaderboardResult { + gameId: string; + scores: GameScoreDoc[]; + error?: string; +} + +interface GameDateEntry { + gameId: string; + status: 'generating' | 'ready'; + userPlayed: boolean; +} + +interface GameDatesResult { + dates?: GameDateEntry[]; + error?: string; +} + +interface GameSubmitResult { + ok?: boolean; + duplicate?: boolean; + existing?: GameScoreDoc; + score?: GameScoreDoc; + error?: string; +} + +interface GameMyScoreResult { + score?: { + mainScore: number; + bonusScore: number; + total: number; + guesses?: { main: Array<'left' | 'right'>; bonus: string[] }; + }; + error?: string; +} + +interface GameQuestionStat { + questionIndex: number; + leftPct: number; + rightPct: number; + bonusOptions: { name: string; pct: number }[]; +} + +interface GameStatsResult { + gameId?: string; + totalPlayers: number; + questions: GameQuestionStat[]; + error?: string; +} + +interface RecentTrack { + trackName: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + album?: string; + albumId?: string; + imageUrl?: string; + uri?: string; + lastPlayed: number; +} + +interface RecentArtist { + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + imageUrl?: string; + lastPlayed: number; +} + +interface RecentAlbum { + album: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + albumId?: string; + imageUrl?: string; + lastPlayed: number; +} + +interface RecentlyPlayedData { + tracks: RecentTrack[]; + artists: RecentArtist[]; + albums: RecentAlbum[]; + availableUsers?: string[]; +} + +interface DjTrack { + uri: string; + trackName: string; + artist: string; + serviceId?: string; + accountId?: string; + albumId?: string; + imageUrl?: string; + /** Group score, 0..1 — higher means less likely to annoy anyone listening. */ + score: number; + /** Why this track was picked, e.g. "Rich and Alex queue Royal Blood". */ + because: string; +} + +/** What the autoplay coordinator decided this round. */ +interface AutoplayResult { + enabled: boolean; + /** Only the lease holder may act on `enqueue`. */ + leaseHeld: boolean; + /** The track this client should append to the queue, unattributed. */ + enqueue: DjTrack | null; + /** The three picks every client previews below the queue. */ + upcoming: DjTrack[]; + /** URI of the filler parked at the tail — user picks go in front of it. */ + fillerUri: string | null; + error?: string; +} + +interface AutoplayRequestBody { + groupId: string; + clientId: string; + queueUris: string[]; + /** Name-based identity per queue entry, index-aligned with queueUris. */ + queueKeys?: string[]; + nowPlayingUri?: string | null; + nowPlayingKey?: string | null; + /** Zero-based playhead position — the reliable signal. */ + nowPlayingIndex?: number | null; + users?: string[]; + setEnabled?: boolean; +} + +/** Who Graph says is in the office, and how confident that is. */ +interface OfficePresence { + inOffice: string[]; + /** + * 'sensed' — Teams detected them on the office network (trusted) + * 'rostered' — their Outlook working pattern claims office today + * 'availability' — merely online; includes people working from home + * 'none' — no presence available; fall back to queue inference + */ + basis: 'sensed' | 'rostered' | 'availability' | 'none'; + /** How many of `inOffice` Teams detected on the network, rather than rostered. */ + observed: number; + detail: Array<{ userId: string; availability?: string; workLocationType?: string; source?: string }>; + error?: string; +} + +/** A ranked act, plus what the office has already played by them. */ +interface DjArtist { + artist: string; + artistId?: string; + serviceId?: string; + accountId?: string; + imageUrl?: string; + score: number; + because: string; + /** "trackName||artist" for each track by this act already in the history. */ + heardTrackKeys: string[]; +} + +interface DjSetResult { + tracks: DjTrack[]; + /** Acts to browse for music the office has genuinely never played. */ + artists: DjArtist[]; + /** Listeners the set was built for, after dropping anyone with no history. */ + listeners: string[]; + artistsConsidered: number; + /** True when the room was inferred from recent activity rather than supplied. */ + inferredListeners: boolean; + error?: string; +} + +/** + * What the IPC bridge actually hands back. A failed fetch returns `{ error }` + * alone, so every field is optional here — `useDjSet` fills the gaps once so + * components never touch a half-shaped object. + */ +type DjSetResponse = Partial & { error?: string }; + +interface EntraUser { + oid: string; + name: string; + email: string; +} + +interface UserProfile { + id: string; + imageUrl?: string | null; + updatedAt?: number; +} + +interface UserSummary { + userId: string; + lastQueued: number; + imageUrl?: string | null; +} + +interface PlaylistTrack { + uri: string; + trackName: string; + artist: string; + albumName?: string; + imageUrl?: string | null; + serviceId: string; + accountId: string; + addedBy: string; + addedAt: number; +} + +interface PlaylistMeta { + id: string; + name: string; + owner: string; + isPublic: boolean; + isFavourites?: boolean; + memberCount: number; + trackCount: number; + updatedAt: number; + imageUrl?: string | null; +} + +interface PlaylistDoc extends PlaylistMeta { + members: string[]; + tracks: PlaylistTrack[]; + createdAt: number; + imageUrl?: string | null; +} + +interface SonosPreload { + getVersion: () => Promise; + isNewVersion: () => Promise; + openExternal: (url: string) => Promise; + onAuthReady: (cb: VoidCallback) => Unsubscribe; + onAuthExpired: (cb: VoidCallback) => Unsubscribe; + fetch: (request: FetchRequest) => Promise; + onWsMessage: (cb: (header: unknown, payload: unknown) => void) => Unsubscribe; + onWsReady: (cb: VoidCallback) => Unsubscribe; + onWsGroups: (cb: (groups: unknown[]) => void) => Unsubscribe; + getActiveGroup: () => Promise; + setGroup: (groupId: string) => Promise<{ ok?: boolean; error?: string }>; + setGroupVolume: (volume: number) => Promise; + setQueueId: (queueId: string) => Promise; + loadContent: (payload: Record) => Promise; + fetchImage: (url: string) => Promise<{ data: string; mimeType: string } | { error: string }>; + refreshPlayback: () => Promise; + resync: () => Promise; + setPlayModes: (modes: Record) => Promise; + play: () => Promise; + pause: () => Promise; + skipNext: () => Promise; + skipPrev: () => Promise; + skipToTrack: (trackNumber: number) => Promise; + seek: (positionMillis: number) => Promise; + reorderQueue: (fromIndices: number[], toIndex: number, queueLength: number) => Promise; + removeFromQueue: (indices: number[]) => Promise; + clearQueue: () => Promise; + openWsMonitor: () => Promise; + openHttpMonitor: () => Promise; + openDevTools: () => Promise; + openMiniPlayer: () => Promise; + closeMiniPlayer: () => Promise; + // Attribution / office presence + getDisplayName: () => Promise; + setDisplayName: (name: string) => Promise<{ error: string } | null>; + getQueueDockedWidth: () => Promise; + setQueueDockedWidth: (width: number) => Promise; + publishQueued: (item: { + eventType: 'track' | 'album'; + uri: string; + trackName: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + album?: string; + albumId?: string; + imageUrl?: string; + }) => Promise; + fetchRecentlyPlayed: (userId: string) => Promise; + fetchDjSet: (opts?: { users?: string[]; limit?: number; excludeUris?: string[] }) => Promise; + fetchOfficePresence: () => Promise; + djAutoplay: (body: AutoplayRequestBody) => Promise; + fetchStats: (period: string, userId?: string, count?: number) => Promise; + fetchDailyGame: (date?: string) => Promise; + submitGameScore: (input: { + gameId: string; + userName: string; + guesses: { main: Array<'left' | 'right'>; bonus: string[] }; + }) => Promise; + fetchGameLeaderboard: (date?: string) => Promise; + fetchGameDates: (userName: string) => Promise; + fetchMyScore: (gameId: string, userName: string) => Promise; + fetchGameStats: (date?: string) => Promise; + refreshAttribution: () => Promise; + onAttributionMap: (cb: (map: AttributionMap) => void) => Unsubscribe; + onAttributionEvent: (cb: (event: AttributionEvent) => void) => Unsubscribe; + geniusDescription: (trackName: string, artistName: string) => Promise; + geniusArtist: (artistName: string, trackHint?: string) => Promise; + geniusAlbumYear: (albumName: string, artistName: string) => Promise; + /** Fire-and-forget telemetry event routed through the main process. No-op when App Insights is not configured. */ + trackEvent: (name: string, properties?: Record) => Promise; + minimizeWindow: () => Promise; + maximizeWindow: () => Promise; + closeWindow: () => Promise; + isWindowMaximized: () => Promise; + onWindowMaximized: (cb: (maximized: boolean) => void) => Unsubscribe; + onUpdateDownloaded: (cb: (version: string) => void) => Unsubscribe; + installUpdate: () => Promise; + ensureFavourites: () => Promise; + fetchPlaylists: (filter: { owner?: string; member?: string }) => Promise; + fetchPlaylist: (id: string) => Promise; + createPlaylist: (name: string, isPublic: boolean) => Promise; + updatePlaylist: (playlistId: string, patch: { name?: string; isPublic?: boolean }) => Promise; + deletePlaylist: (playlistId: string) => Promise<{ ok?: boolean; error?: string }>; + addTrackToPlaylist: (playlistId: string, track: PlaylistTrack) => Promise; + removeTrackFromPlaylist: (playlistId: string, uri: string) => Promise; + reorderPlaylistTracks: (playlistId: string, fromIndex: number, toIndex: number) => Promise; + joinPlaylist: (playlistId: string, action: 'join' | 'leave') => Promise; + uploadPlaylistImage: (playlistId: string, data: ArrayBuffer, mimeType: string, userName: string) => Promise<{ imageUrl: string } | { error: string }>; + fetchUsers: (includeSelf?: boolean) => Promise; + fetchUserProfile: (userName: string) => Promise; + uploadProfileImage: (userName: string, data: ArrayBuffer, mimeType: string) => Promise<{ imageUrl: string } | { error: string }>; + getEntraUser: () => Promise; + entraSignOut: () => Promise; + entraReLogin: () => Promise; + onEntraReady: (cb: (user: EntraUser) => void) => Unsubscribe; + renameUser: (oldName: string, newName: string) => Promise<{ ok?: boolean; error?: string }>; +} + +interface Window { + sonos: SonosPreload; +} diff --git a/apps/mobile/src/ui/Art.tsx b/apps/mobile/src/ui/Art.tsx new file mode 100644 index 0000000..f308e18 --- /dev/null +++ b/apps/mobile/src/ui/Art.tsx @@ -0,0 +1,40 @@ +import { Image } from 'expo-image'; +import { Music } from 'lucide-react-native'; +import { StyleSheet, View } from 'react-native'; +import type { StyleProp, ViewStyle } from 'react-native'; +import { colors, radius } from './theme'; + +/** Album/artist art with a placeholder. expo-image caches by URL, so no manual image cache. */ +export function Art({ + uri, + size, + round = false, + style, +}: { + uri: string | null | undefined; + size: number; + round?: boolean; + style?: StyleProp; +}) { + const box = { width: size, height: size, borderRadius: round ? size / 2 : radius.md }; + return ( + + {uri ? ( + __DEV__ && console.warn('[art] failed', uri, e.error)} + /> + ) : ( + + )} + + ); +} + +const styles = StyleSheet.create({ + box: { backgroundColor: colors.bg1, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }, +}); diff --git a/apps/mobile/src/ui/ConnectionBanner.tsx b/apps/mobile/src/ui/ConnectionBanner.tsx new file mode 100644 index 0000000..c2f88bb --- /dev/null +++ b/apps/mobile/src/ui/ConnectionBanner.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { core } from '../platform/core'; +import { useConnectionStatus } from '../platform/useConnectionStatus'; +import { colors, radius, space, type } from './theme'; + +/** How long we must be disconnected before showing anything, so brief blips and startup stay quiet. */ +const SHOW_AFTER_MS = 1500; + +/** Floating pill shown while the Sonos connection is down or being re-established. Tap to retry. */ +export function ConnectionBanner() { + const status = useConnectionStatus(); + const insets = useSafeAreaInsets(); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (status === 'connected') { + setVisible(false); + return; + } + const t = setTimeout(() => setVisible(true), SHOW_AFTER_MS); + return () => clearTimeout(t); + }, [status]); + + if (!visible) return null; + return ( + core.onForeground()} + accessibilityLabel="Reconnecting to Sonos. Tap to retry" + > + + {status === 'connecting' ? 'Connecting to Sonos…' : 'Sonos disconnected. Tap to retry'} + + ); +} + +const styles = StyleSheet.create({ + pill: { + position: 'absolute', + alignSelf: 'center', + flexDirection: 'row', + alignItems: 'center', + gap: space.sm, + paddingHorizontal: space.lg, + paddingVertical: space.sm, + borderRadius: radius.pill, + backgroundColor: colors.bg1, + borderColor: colors.border2, + borderWidth: StyleSheet.hairlineWidth, + zIndex: 10, + elevation: 10, + }, +}); diff --git a/apps/mobile/src/ui/ImagePrefetcher.tsx b/apps/mobile/src/ui/ImagePrefetcher.tsx new file mode 100644 index 0000000..b60d1b9 --- /dev/null +++ b/apps/mobile/src/ui/ImagePrefetcher.tsx @@ -0,0 +1,47 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { useNowPlaying } from '../logic/hooks/useNowPlaying'; +import { trackQueryOptions } from '../logic/hooks/useTrackDetails'; +import { currentQueueIndex, prefetchWindow, runLimited } from '../logic/lib/prefetch'; +import { useRemoteContext } from '../logic/RemoteContext'; +import { warmImage } from './imagePrefetch'; + +/** Fetch track details for this many rows at once; the Sonos API is rate sensitive. */ +const CONCURRENCY = 2; + +/** + * Renders nothing. Keeps the artwork the user is about to see already downloaded: the playing + * track's cover, and for the queue the details (which is where queue art comes from) and image of + * what plays next, plus the top of the list. The details land in the same React Query cache the + * queue rows read, so rows also render their text without a flash. + */ +export function ImagePrefetcher() { + const { playback, queue } = useRemoteContext(); + const np = useNowPlaying(playback); + const queryClient = useQueryClient(); + + useEffect(() => { + warmImage(np.artUrlRaw); + }, [np.artUrlRaw]); + + const index = currentQueueIndex(queue.items, playback.currentObjectId, playback.queueItemId); + const count = queue.items.length; + + useEffect(() => { + if (count === 0) return; + let cancelled = false; + const tasks = prefetchWindow(queue.items, index).map((it) => async () => { + const { id, serviceId, accountId } = it.track; + const details = await queryClient.fetchQuery(trackQueryOptions(id, serviceId as string, accountId as string)); + if (!cancelled) warmImage(details?.artUrl ?? it.track.imageUrl); + }); + void runLimited(tasks, CONCURRENCY, () => cancelled); + return () => { + cancelled = true; + }; + // Re-plan when the queue changes size or the playing item moves; items identity churns on every reload. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [count, index, queryClient]); + + return null; +} diff --git a/apps/mobile/src/ui/MiniPlayer.tsx b/apps/mobile/src/ui/MiniPlayer.tsx new file mode 100644 index 0000000..a41fb00 --- /dev/null +++ b/apps/mobile/src/ui/MiniPlayer.tsx @@ -0,0 +1,69 @@ +import { router } from 'expo-router'; +import { Pause, Play } from 'lucide-react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useNowPlaying } from '../logic/hooks/useNowPlaying'; +import { getActiveProvider } from '../logic/providers'; +import { useRemoteContext } from '../logic/RemoteContext'; +import { Art } from './Art'; +import { haptic } from './haptics'; +import { colors, radius, space, type } from './theme'; + +/** Persistent bar above the tabs; tap to open the full Now Playing screen. */ +export function MiniPlayer() { + const { playback, activeGroup } = useRemoteContext(); + // Track details (art, clean title/artist) layered over the raw playback state, as on desktop. + const np = useNowPlaying(playback); + // Nothing loaded (idle group): no bar. Rooms stay reachable from the More tab. + if (!playback.isVisible || !np.displayTrack) return null; + const provider = getActiveProvider(); + + return ( + router.push('/player')} accessibilityLabel="Open now playing"> + + + + + + + {np.displayTrack} + + + {np.displayArtist} + {activeGroup ? ` · ${activeGroup.name}` : ''} + + + { + haptic.light(); + void (playback.isPlaying ? provider.pause() : provider.play()); + }} + accessibilityLabel={playback.isPlaying ? 'Pause' : 'Play'} + > + {playback.isPlaying ? : } + + + ); +} + +const styles = StyleSheet.create({ + bar: { + flexDirection: 'row', + alignItems: 'center', + gap: space.md, + marginHorizontal: space.sm, + marginBottom: space.sm, + padding: space.sm, + paddingTop: space.md, + backgroundColor: colors.bg1, + borderRadius: radius.lg, + borderColor: colors.border, + borderWidth: StyleSheet.hairlineWidth, + overflow: 'hidden', + }, + progressTrack: { position: 'absolute', top: 0, left: 0, right: 0, height: 2, backgroundColor: colors.border }, + progressFill: { height: 2, backgroundColor: colors.text }, + text: { flex: 1 }, + play: { padding: space.sm }, +}); diff --git a/apps/mobile/src/ui/Slider.tsx b/apps/mobile/src/ui/Slider.tsx new file mode 100644 index 0000000..0550490 --- /dev/null +++ b/apps/mobile/src/ui/Slider.tsx @@ -0,0 +1,81 @@ +import { useRef, useState } from 'react'; +import { PanResponder, StyleSheet, View } from 'react-native'; +import { colors } from './theme'; + +/** + * Touch slider (seek / volume). Tap or drag; reports the final value on release and, optionally, + * live values while dragging. Controlled by `value` except while the finger is down. + */ +export function Slider({ + value, + max, + onCommit, + onSlide, + height = 4, + color = colors.text, +}: { + value: number; + max: number; + onCommit: (value: number) => void; + onSlide?: (value: number) => void; + height?: number; + color?: string; +}) { + const [width, setWidth] = useState(0); + const [dragFraction, setDragFraction] = useState(null); + const startFraction = useRef(0); + // PanResponder is created once; keep the latest props/width reachable from it. + const latest = useRef({ width, max, onCommit, onSlide }); + latest.current = { width, max, onCommit, onSlide }; + + const clamp = (f: number) => Math.min(1, Math.max(0, f)); + + const pan = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onPanResponderTerminationRequest: () => false, + onPanResponderGrant: (e) => { + const { width: w, max: m, onSlide: slide } = latest.current; + if (!w) return; + startFraction.current = clamp(e.nativeEvent.locationX / w); + setDragFraction(startFraction.current); + slide?.(startFraction.current * m); + }, + onPanResponderMove: (_e, g) => { + const { width: w, max: m, onSlide: slide } = latest.current; + if (!w) return; + const f = clamp(startFraction.current + g.dx / w); + setDragFraction(f); + slide?.(f * m); + }, + onPanResponderRelease: (_e, g) => { + const { width: w, max: m, onCommit: commit } = latest.current; + if (w) commit(clamp(startFraction.current + g.dx / w) * m); + setDragFraction(null); + }, + onPanResponderTerminate: () => setDragFraction(null), + }), + ).current; + + const fraction = dragFraction ?? (max > 0 ? clamp(value / max) : 0); + + return ( + setWidth(e.nativeEvent.layout.width)} {...pan.panHandlers}> + + + + + + ); +} + +const styles = StyleSheet.create({ + hit: { height: 32, justifyContent: 'center' }, + track: { backgroundColor: colors.border2, overflow: 'hidden' }, + fill: { height: '100%' }, + thumb: { position: 'absolute', width: 14, height: 14, borderRadius: 7, marginLeft: -7 }, +}); diff --git a/apps/mobile/src/ui/SwipeRow.tsx b/apps/mobile/src/ui/SwipeRow.tsx new file mode 100644 index 0000000..a6d8767 --- /dev/null +++ b/apps/mobile/src/ui/SwipeRow.tsx @@ -0,0 +1,96 @@ +import { ListPlus, Trash2 } from 'lucide-react-native'; +import { memo, useCallback, useRef } from 'react'; +import type { ReactNode } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import type { StyleProp, ViewStyle } from 'react-native'; +import ReanimatedSwipeable from 'react-native-gesture-handler/ReanimatedSwipeable'; +import type { SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable'; +import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated'; +import type { SharedValue } from 'react-native-reanimated'; +import { colors, space } from './theme'; +import { haptic } from './haptics'; + +const ACTION_WIDTH = 96; +/** Release past this distance commits the action; shorter swipes just spring back. */ +const COMMIT_DISTANCE = 64; +/** Horizontal travel before the pan activates, so vertical list scrolling is not hijacked. */ +const ACTIVATE_OFFSET = 20; +const NEVER = 10000; + +interface Props { + children: ReactNode; + /** Swipe right: reveals the left-side "Add to queue" action. */ + onSwipeRight?: () => void; + /** Swipe left: reveals the right-side "Remove" action. */ + onSwipeLeft?: () => void; + disabled?: boolean; + style?: StyleProp; +} + +function Action({ + progress, side, label, bg, children, +}: { progress: SharedValue; side: 'left' | 'right'; label: string; bg: string; children: ReactNode }) { + const anim = useAnimatedStyle(() => ({ + opacity: interpolate(progress.value, [0, 0.4, 1], [0, 0.6, 1], 'clamp'), + transform: [{ scale: interpolate(progress.value, [0, 1], [0.6, 1], 'clamp') }], + })); + return ( + + + {children} + {label} + + + ); +} + +function SwipeRowImpl({ children, onSwipeRight, onSwipeLeft, disabled, style }: Props) { + const ref = useRef(null); + + const onOpen = useCallback((direction: 'left' | 'right') => { + // 'right' = row dragged right (left actions revealed). + haptic.medium(); + if (direction === 'right') onSwipeRight?.(); + else onSwipeLeft?.(); + ref.current?.close(); + }, [onSwipeRight, onSwipeLeft]); + + if (disabled || (!onSwipeRight && !onSwipeLeft)) return {children}; + + return ( + ( + + + + ) : undefined} + renderRightActions={onSwipeLeft ? (p) => ( + + + + ) : undefined} + > + {children} + + ); +} + +export const SwipeRow = memo(SwipeRowImpl); + +const styles = StyleSheet.create({ + // Opaque so the action panels behind the row are hidden until it is dragged. + content: { backgroundColor: colors.bg }, + action: { width: ACTION_WIDTH, justifyContent: 'center', paddingHorizontal: space.lg }, + actionInner: { alignItems: 'center', gap: 2 }, + actionLabel: { color: colors.text, fontSize: 11, fontWeight: '600' }, +}); diff --git a/apps/mobile/src/ui/Toast.tsx b/apps/mobile/src/ui/Toast.tsx new file mode 100644 index 0000000..16851e6 --- /dev/null +++ b/apps/mobile/src/ui/Toast.tsx @@ -0,0 +1,55 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ToastContext } from '../logic/context/ToastContext'; +import type { ToastType } from '../logic/context/ToastContext'; +import { colors, radius, space } from './theme'; + +const RemoteToastContext = createContext<(msg: string, type?: ToastType) => void>(() => {}); +export const useShowToast = () => useContext(RemoteToastContext); + +/** Provides the shared ToastContext (used by the ported hooks) and renders the toast overlay. */ +export function ToastProvider({ children }: { children: ReactNode }) { + const [toast, setToast] = useState<{ message: string; type: ToastType } | null>(null); + const timer = useRef | null>(null); + const insets = useSafeAreaInsets(); + + const showToast = useCallback((message: string, type: ToastType = 'info') => { + setToast({ message, type }); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setToast(null), 4000); + }, []); + + const value = useMemo(() => ({ showToast }), [showToast]); + + return ( + + + {children} + {toast && ( + + + {toast.message} + + + )} + + + ); +} + +const styles = StyleSheet.create({ + wrap: { position: 'absolute', left: space.lg, right: space.lg, alignItems: 'center' }, + toast: { + backgroundColor: colors.bg1, + borderColor: colors.border2, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: radius.md, + paddingHorizontal: space.lg, + paddingVertical: space.md, + }, + error: { borderColor: colors.danger }, + success: { borderColor: colors.success }, + text: { color: colors.text, fontSize: 14 }, +}); diff --git a/apps/mobile/src/ui/detail/BrowseTrackRow.tsx b/apps/mobile/src/ui/detail/BrowseTrackRow.tsx new file mode 100644 index 0000000..91b4d0e --- /dev/null +++ b/apps/mobile/src/ui/detail/BrowseTrackRow.tsx @@ -0,0 +1,48 @@ +import { memo } from 'react'; +import { StyleSheet, Text } from 'react-native'; +import type { AlbumTrack } from '../../logic/hooks/useAlbumBrowse'; +import type { SonosItem } from '../../logic/types/sonos'; +import { MediaRow } from '../media'; +import { colors, type } from '../theme'; +import { durationLabel } from './detailLogic'; + +/** Album / playlist / top-song row: ordinal, art (optional), title + explicit badge, artist, duration, add. */ +export const BrowseTrackRow = memo(function BrowseTrackRow({ + track, + ordinal, + fallbackArt, + subtitle, + isPlaying, + onAdd, + onPress, + onLongPress, +}: { + track: AlbumTrack; + ordinal: number; + fallbackArt?: string | null; + subtitle?: string; + isPlaying?: boolean; + onAdd: (item: SonosItem) => void; + onPress: () => void; + onLongPress: () => void; +}) { + return ( + {ordinal}} + trailing={{durationLabel(track.durationSeconds)}} + onAdd={() => onAdd(track.raw)} + onSwipeAdd={() => onAdd(track.raw)} + onPress={onPress} + onLongPress={onLongPress} + /> + ); +}); + +const styles = StyleSheet.create({ + ordinal: { width: 24, textAlign: 'right' }, +}); diff --git a/apps/mobile/src/ui/detail/DetailChrome.tsx b/apps/mobile/src/ui/detail/DetailChrome.tsx new file mode 100644 index 0000000..9dee51d --- /dev/null +++ b/apps/mobile/src/ui/detail/DetailChrome.tsx @@ -0,0 +1,95 @@ +// Small shared pieces for the detail screens: top bar with back button, state messages, buttons. +import { router } from 'expo-router'; +import { ChevronLeft } from 'lucide-react-native'; +import type { ReactNode } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; +import type { StyleProp, ViewStyle } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { colors, radius, space, type } from '../theme'; + +export function goBack() { + if (router.canGoBack()) router.back(); + else router.replace('/'); +} + +/** The root Stack has no native header, so every detail screen draws its own back bar. */ +export function DetailTopBar({ title, right }: { title?: string; right?: ReactNode }) { + const insets = useSafeAreaInsets(); + return ( + + + + + + {title ?? ''} + + {right} + + ); +} + +export function Centered({ loading, message }: { loading?: boolean; message?: string }) { + return ( + + {loading ? : null} + {message ? {message} : null} + + ); +} + +export function PillButton({ + label, + icon, + onPress, + disabled, + primary, + danger, + style, +}: { + label: string; + icon?: ReactNode; + onPress: () => void; + disabled?: boolean; + primary?: boolean; + danger?: boolean; + style?: StyleProp; +}) { + return ( + [ + styles.pill, + primary && { backgroundColor: colors.text }, + danger && { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.danger }, + (pressed || disabled) && { opacity: disabled ? 0.4 : 0.7 }, + style, + ]} + > + {icon} + {label} + + ); +} + +/** Solid tint from an "r, g, b" dominant colour (optional: the RN sampler may return null). */ +export const tintBackground = (rgb: string | null, alpha = 0.35) => (rgb ? `rgba(${rgb}, ${alpha})` : 'transparent'); + +const styles = StyleSheet.create({ + bar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: space.sm, paddingBottom: space.xs, gap: space.xs }, + back: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, + barTitle: { flex: 1 }, + right: { minWidth: 44, alignItems: 'flex-end' }, + centered: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: space.md, padding: space.xl }, + pill: { + minHeight: 44, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: space.sm, + paddingHorizontal: space.lg, + borderRadius: radius.pill, + backgroundColor: colors.bg2, + }, +}); diff --git a/apps/mobile/src/ui/detail/GeniusText.tsx b/apps/mobile/src/ui/detail/GeniusText.tsx new file mode 100644 index 0000000..58ce0e6 --- /dev/null +++ b/apps/mobile/src/ui/detail/GeniusText.tsx @@ -0,0 +1,40 @@ +// Renders a Genius description tree (GeniusDomNode) as native text. No HTML/marked in RN, so the +// tree is flattened by geniusToBlocks() into paragraphs of styled runs. +import { useMemo } from 'react'; +import { Linking, StyleSheet, Text, View } from 'react-native'; +import { colors, space, type } from '../theme'; +import { geniusToBlocks } from './detailLogic'; + +export function GeniusText({ node, maxBlocks }: { node: GeniusDomNode | null | undefined; maxBlocks?: number }) { + const blocks = useMemo(() => geniusToBlocks(node), [node]); + const shown = maxBlocks ? blocks.slice(0, maxBlocks) : blocks; + if (!shown.length) return null; + return ( + + {shown.map((b, i) => ( + + {b.kind === 'li' ? '• ' : ''} + {b.runs.map((r, j) => ( + void Linking.openURL(r.href!).catch(() => {}) : undefined} + > + {r.text} + + ))} + + ))} + + ); +} + +const styles = StyleSheet.create({ + text: { lineHeight: 20 }, + heading: { color: colors.text, fontWeight: '700' }, + link: { color: colors.accent }, +}); diff --git a/apps/mobile/src/ui/detail/detailLogic.ts b/apps/mobile/src/ui/detail/detailLogic.ts new file mode 100644 index 0000000..792b666 --- /dev/null +++ b/apps/mobile/src/ui/detail/detailLogic.ts @@ -0,0 +1,234 @@ +// Pure helpers behind the detail screens (no React Native imports so vitest can cover them). +import type { AlbumTrack } from '../../logic/hooks/useAlbumBrowse'; +import { fmtDuration } from '../../logic/lib/itemHelpers'; +import { getPlaylistColor } from '../../logic/lib/playlistColor'; +import type { SonosItem, SonosItemId } from '../../logic/types/sonos'; + +// ─── Route params ──────────────────────────────────────────────────────────── + +type Param = string | string[] | undefined; + +export interface DetailParams { + id?: string; + serviceId?: string; + accountId?: string; + defaults?: string; + name?: string; +} + +const one = (p: Param): string | undefined => { + const v = Array.isArray(p) ? p[0] : p; + return v ? v : undefined; +}; + +/** Reads the params documented in ui/media/routes.ts. accountId loses any "sn_" prefix defensively. */ +export function parseDetailParams(p: Record): DetailParams { + return { + id: one(p.id), + serviceId: one(p.serviceId), + accountId: one(p.accountId)?.replace(/^sn_/, ''), + defaults: one(p.defaults), + name: one(p.name), + }; +} + +/** Route for the create-playlist modal, optionally carrying the track to drop into the new playlist. */ +export function createPlaylistRoute(track?: PlaylistTrack | null): string { + return track ? `/create-playlist?track=${encodeURIComponent(JSON.stringify(track))}` : '/create-playlist'; +} + +export function parsePendingTrack(p: Param): PlaylistTrack | null { + const raw = one(p); + if (!raw) return null; + try { + const t = JSON.parse(raw) as Partial; + return t && typeof t.uri === 'string' && t.uri && typeof t.trackName === 'string' ? (t as PlaylistTrack) : null; + } catch { + return null; + } +} + +// ─── Synthetic items (what desktop got from router state) ──────────────────── + +/** Builds the SonosItem that addToQueue needs for an album/playlist opened by route params. */ +export function buildBrowseItem(kind: 'ALBUM' | 'PLAYLIST', p: DetailParams, extra: { title?: string } = {}): SonosItem | null { + if (!p.id) return null; + return { + type: kind, + name: extra.title ?? p.name, + title: extra.title ?? p.name, + resource: { + type: kind, + id: { objectId: p.id, serviceId: p.serviceId, accountId: p.accountId }, + defaults: p.defaults, + }, + }; +} + +/** Desktop PlaylistPanel.trackToSonosItem. */ +export function playlistTrackToItem(t: PlaylistTrack): SonosItem { + return { + type: 'TRACK', + title: t.trackName, + artist: t.artist, + imageUrl: t.imageUrl ?? undefined, + id: { objectId: t.uri, serviceId: t.serviceId, accountId: t.accountId }, + }; +} + +// ─── Track lists ───────────────────────────────────────────────────────────── + +export function albumMetaLine(totalTracks: number, totalSeconds: number, year: number | null): string { + const mins = Math.round(totalSeconds / 60); + return [`${totalTracks} songs`, mins > 0 ? `${mins} min` : null, year].filter(Boolean).join(' • '); +} + +export function totalSeconds(tracks: Pick[]): number { + return tracks.reduce((s, t) => s + (t.durationSeconds || 0), 0); +} + +export const durationLabel = (seconds: number): string => (seconds > 0 ? fmtDuration(seconds) : ''); + +/** + * The item handed to the long-press sheet for a browse track: the raw track normalised so the + * sheet's queue/playlist/go-to actions work (object id, credited artists with ids, album ref). + */ +export function trackSheetItem( + track: AlbumTrack, + ctx: { serviceId?: string; accountId?: string; includeAlbum?: boolean }, +): SonosItem { + const rid = track.id as SonosItemId; + const serviceId = rid.serviceId ?? ctx.serviceId; + const accountId = (rid.accountId ?? ctx.accountId)?.replace(/^sn_/, ''); + const id: SonosItemId = { objectId: rid.objectId, serviceId, accountId }; + const imageUrl = track.artUrl ?? undefined; + const artists = (track.artistObjects ?? []).map((a) => ({ + name: a.name, + id: { objectId: a.objectId, serviceId, accountId } as SonosItemId, + })); + const base: SonosItem = { + ...track.raw, + type: 'TRACK', + title: track.title, + name: track.title, + id, + explicit: track.explicit, + imageUrl, + resource: { ...(track.raw.resource ?? {}), type: 'TRACK', id }, + track: { + id, + name: track.title, + artist: track.artists[0] ? { name: track.artists[0] } : undefined, + imageUrl, + images: imageUrl ? [{ url: imageUrl }] : undefined, + }, + artists: artists.length ? artists : track.artists.map((name) => ({ name })), + }; + if (ctx.includeAlbum && track.albumId && track.albumName) { + base.album = { name: track.albumName, id: { objectId: track.albumId, serviceId, accountId } }; + } + return base; +} + +// ─── Playlist reorder ──────────────────────────────────────────────────────── + +/** Returns a copy with the item at `from` moved to index `to` (same semantics as the server's reorder). */ +export function moveItem(list: readonly T[], from: number, to: number): T[] { + if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) return [...list]; + const next = [...list]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; +} + +export const canMoveUp = (index: number) => index > 0; +export const canMoveDown = (index: number, length: number) => index < length - 1; + +/** First hsl() stop of the desktop gradient (RN has no CSS gradients without a native dep). */ +export function flatPlaylistColour(name: string): string { + const m = /hsl\([^)]+\)/.exec(getPlaylistColor(name)); + return m ? m[0] : '#252528'; +} + +export function playlistMetaParts(pl: Pick): string[] { + const parts = [pl.isPublic ? 'Public' : 'Private']; + if (pl.isPublic) parts.push(`${pl.members.length} member${pl.members.length !== 1 ? 's' : ''}`); + parts.push(`${pl.tracks.length} track${pl.tracks.length !== 1 ? 's' : ''}`); + return parts; +} + +// ─── Genius DOM ────────────────────────────────────────────────────────────── + +export interface TextRun { + text: string; + bold?: boolean; + italic?: boolean; + href?: string; +} +export interface TextBlock { + kind: 'p' | 'li' | 'heading'; + runs: TextRun[]; +} + +const BLOCK_TAGS = new Set(['p', 'div', 'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']); +const SKIP_TAGS = new Set(['img', 'iframe', 'script', 'style', 'embedded-content']); + +/** + * Flattens a Genius description tree into paragraph blocks of styled runs, ready for . + * Block tags start a new block; inline tags (a, b/strong, i/em) become run styles; br is a newline. + */ +export function geniusToBlocks(root: GeniusDomNode | null | undefined): TextBlock[] { + const blocks: TextBlock[] = []; + let kind: TextBlock['kind'] = 'p'; + let runs: TextRun[] = []; + + const flush = () => { + const joined = runs.map((r) => r.text).join(''); + if (joined.trim()) { + // trim outer whitespace of the block + const first = runs.findIndex((r) => r.text.trim()); + let last = runs.length - 1; + while (last > 0 && !runs[last].text.trim()) last--; + const out = runs.slice(first, last + 1).map((r) => ({ ...r })); + out[0].text = out[0].text.replace(/^\s+/, ''); + out[out.length - 1].text = out[out.length - 1].text.replace(/\s+$/, ''); + blocks.push({ kind, runs: out }); + } + runs = []; + kind = 'p'; + }; + + const walk = (node: GeniusDomNode, style: Omit) => { + if (typeof node === 'string') { + if (node) runs.push({ text: node, ...style }); + return; + } + const tag = (node.tag ?? '').toLowerCase(); + if (SKIP_TAGS.has(tag)) return; + if (tag === 'br') { + runs.push({ text: '\n', ...style }); + return; + } + const isBlock = BLOCK_TAGS.has(tag); + if (isBlock) { + flush(); + if (tag === 'li') kind = 'li'; + else if (/^h[1-6]$/.test(tag)) kind = 'heading'; + } + let next = style; + if (tag === 'b' || tag === 'strong') next = { ...style, bold: true }; + else if (tag === 'i' || tag === 'em') next = { ...style, italic: true }; + else if (tag === 'a' && node.attributes?.href) next = { ...style, href: node.attributes.href }; + for (const c of node.children ?? []) walk(c, next); + if (isBlock) flush(); + }; + + if (root) walk(root, {}); + flush(); + return blocks; +} + +/** Plain text of the first paragraphs (desktop extractBio) — used for accessibility / tests. */ +export function blocksToPlain(blocks: TextBlock[]): string { + return blocks.map((b) => b.runs.map((r) => r.text).join('')).join('\n\n'); +} diff --git a/apps/mobile/src/ui/haptics.ts b/apps/mobile/src/ui/haptics.ts new file mode 100644 index 0000000..7924866 --- /dev/null +++ b/apps/mobile/src/ui/haptics.ts @@ -0,0 +1,22 @@ +// Thin, failure-proof wrapper over expo-haptics. A device without a vibration motor (or a denied +// setting) must never surface as an error, so every call swallows failures. + +import * as Haptics from 'expo-haptics'; + +const fire = (fn: () => Promise) => { + try { + fn().catch(() => {}); + } catch { + /* no haptics available */ + } +}; + +export const haptic = { + /** Tab switches, toggles. */ + selection: () => fire(() => Haptics.selectionAsync()), + /** Small confirmations: play/pause, add buttons. */ + light: () => fire(() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)), + /** Mode changes and gesture commits: long-press select, drag start, swipe action fired. */ + medium: () => fire(() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)), + success: () => fire(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)), +}; diff --git a/apps/mobile/src/ui/imagePrefetch.ts b/apps/mobile/src/ui/imagePrefetch.ts new file mode 100644 index 0000000..ab8f6e3 --- /dev/null +++ b/apps/mobile/src/ui/imagePrefetch.ts @@ -0,0 +1,12 @@ +import { Image } from 'expo-image'; +import { createSeen } from '../logic/lib/prefetch'; + +const requested = createSeen(3000); + +/** Download an image into expo-image's memory + disk cache once, ahead of the screen that shows it. */ +export function warmImage(url: string | null | undefined): void { + if (!url || !requested.add(url)) return; + Image.prefetch(url, 'memory-disk').catch(() => { + // Best effort; allow a retry later if it failed. + }); +} diff --git a/apps/mobile/src/ui/media/ActionSheet.tsx b/apps/mobile/src/ui/media/ActionSheet.tsx new file mode 100644 index 0000000..4ce035b --- /dev/null +++ b/apps/mobile/src/ui/media/ActionSheet.tsx @@ -0,0 +1,180 @@ +// Bottom-sheet replacement for the desktop right-click ContextMenu. +import { useQueryClient } from '@tanstack/react-query'; +import { router } from 'expo-router'; +import { ArrowRight, Heart, ListEnd, ListPlus, ListStart, Plus } from 'lucide-react-native'; +import { useCallback, useState } from 'react'; +import type { ReactNode } from 'react'; +import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useAddToQueue } from '../../logic/AddToQueueContext'; +import { favouritesId, useMyPlaylists } from '../../logic/hooks/usePlaylists'; +import { getName } from '../../logic/lib/itemHelpers'; +import type { SonosItem } from '../../logic/types/sonos'; +import { useShowToast } from '../Toast'; +import { colors, radius, space, type } from '../theme'; +import { createPlaylistRoute } from '../detail/detailLogic'; +import { actionsForItem, toPlaylistTrack } from './itemLogic'; +import type { ItemActionKey } from './itemLogic'; +import { albumRouteForItem, artistRouteForItem } from './routes'; +import { haptic } from '../haptics'; + +export interface SheetAction { + key: string; + label: string; + icon?: ReactNode; + onPress: () => void; +} + +export function ActionSheet({ + visible, + title, + subtitle, + actions, + onClose, +}: { + visible: boolean; + title?: string; + subtitle?: string; + actions: SheetAction[]; + onClose: () => void; +}) { + const insets = useSafeAreaInsets(); + return ( + + + + + {title ? ( + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + ) : null} + + {actions.map((a) => ( + [styles.action, pressed && styles.pressed]}> + {a.icon} + {a.label} + + ))} + + + + ); +} + +const ICON = { size: 20, color: colors.text2 } as const; + +/** + * Long-press menu for a Sonos item. Usage: + * const { show, sheet } = useItemActionSheet(); ... onLongPress={() => show(item)} ... {sheet} + * Actions (desktop parity): Play next (position 0), Add to queue (end), Add to Favourites and + * Add to playlist (tracks, needs displayName); plus mobile-only Go to album / Go to artist. + */ +export function useItemActionSheet(displayName?: string | null) { + const [item, setItem] = useState(null); + const [mode, setMode] = useState<'main' | 'playlists'>('main'); + const addToQueue = useAddToQueue(); + const showToast = useShowToast(); + const queryClient = useQueryClient(); + const { owned, joined } = useMyPlaylists(displayName); + + const close = useCallback(() => { + setItem(null); + setMode('main'); + }, []); + const show = useCallback((i: SonosItem) => { + haptic.medium(); + setMode('main'); + setItem(i); + }, []); + + async function addTrackTo(playlist: PlaylistMeta | { id: string; name: string; owner: string }) { + if (!item) return; + const track = toPlaylistTrack(item, displayName ?? ''); + close(); + try { + await window.sonos.addTrackToPlaylist(playlist.id, track); + queryClient.invalidateQueries({ queryKey: ['playlist', playlist.id] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', playlist.owner] }); + if (displayName && playlist.owner !== displayName) { + queryClient.invalidateQueries({ queryKey: ['playlists', 'joined', displayName] }); + } + showToast(`Added to ${playlist.name}`, 'success'); + } catch { + showToast(`Failed to add to ${playlist.name}`, 'error'); + } + } + + let actions: SheetAction[] = []; + if (item) { + const albumHref = albumRouteForItem(item); + const artistHref = artistRouteForItem(item); + const keys = actionsForItem(item, { + hasAlbumRoute: !!albumHref, + hasArtistRoute: !!artistHref, + canPlaylist: !!displayName, + }); + const build: Record = { + playNext: { key: 'playNext', label: 'Play next', icon: , onPress: () => { void addToQueue(item, 0); close(); } }, + addToQueue: { key: 'addToQueue', label: 'Add to queue', icon: , onPress: () => { void addToQueue(item, -1); close(); } }, + goToAlbum: { key: 'goToAlbum', label: 'Go to album', icon: , onPress: () => { close(); if (albumHref) router.push(albumHref as never); } }, + goToArtist: { key: 'goToArtist', label: 'Go to artist', icon: , onPress: () => { close(); if (artistHref) router.push(artistHref as never); } }, + favourite: { key: 'favourite', label: 'Add to Favourites', icon: , onPress: () => void addTrackTo({ id: favouritesId(displayName ?? ''), name: 'Favourites', owner: displayName ?? '' }) }, + addToPlaylist: { key: 'addToPlaylist', label: 'Add to playlist…', icon: , onPress: () => setMode('playlists') }, + }; + actions = keys.map((k) => build[k]); + if (mode === 'playlists') { + actions = [...owned, ...joined].map((pl) => ({ + key: pl.id, + label: pl.name, + icon: , + onPress: () => void addTrackTo(pl), + })); + actions.push({ + key: 'create-playlist', + label: 'Create new playlist', + icon: , + onPress: () => { + const track = toPlaylistTrack(item, displayName ?? ''); + close(); + router.push(createPlaylistRoute(track) as never); + }, + }); + } + } + + const sheet = ( + + ); + return { show, sheet }; +} + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }, + sheet: { + backgroundColor: colors.bg1, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + maxHeight: '70%', + paddingTop: space.sm, + }, + grabber: { alignSelf: 'center', width: 36, height: 4, borderRadius: 2, backgroundColor: colors.border2, marginBottom: space.sm }, + head: { paddingHorizontal: space.lg, paddingBottom: space.sm, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border }, + list: { flexGrow: 0 }, + action: { minHeight: 52, flexDirection: 'row', alignItems: 'center', gap: space.md, paddingHorizontal: space.lg }, + pressed: { backgroundColor: colors.bg2 }, + icon: { width: 24, alignItems: 'center' }, +}); diff --git a/apps/mobile/src/ui/media/components.tsx b/apps/mobile/src/ui/media/components.tsx new file mode 100644 index 0000000..08685ce --- /dev/null +++ b/apps/mobile/src/ui/media/components.tsx @@ -0,0 +1,292 @@ +// Presentational media components ported from the desktop MediaRow / MediaCard / CardRow / +// PlaylistCard / ArtistCircle / ExplicitBadge. Touch: rows >= 56px, add buttons 44px hit area, +// long-press replaces right-click. +import { Plus } from 'lucide-react-native'; +import type { ReactNode } from 'react'; +import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useArtistImage } from '../../logic/hooks/useArtistBrowse'; +import { browseSub, getItemArt, getName, isArtist } from '../../logic/lib/itemHelpers'; +import { getPlaylistColor } from '../../logic/lib/playlistColor'; +import type { SonosItem, SonosItemId } from '../../logic/types/sonos'; +import { Art } from '../Art'; +import { SwipeRow } from '../SwipeRow'; +import { colors, radius, space, type } from '../theme'; +import { haptic } from '../haptics'; + +export function ExplicitBadge() { + return ( + + E + + ); +} + +export function AddButton({ onPress, size = 44 }: { onPress: () => void; size?: number }) { + return ( + { + haptic.light(); + onPress(); + }} + accessibilityLabel="Add to queue" + style={({ pressed }) => [styles.addBtn, { width: size, height: size }, pressed && { opacity: 0.6 }]} + > + + + ); +} + +// ─── MediaRow ──────────────────────────────────────────────────────────────── + +export interface MediaRowProps { + name: string; + artUrl?: string | null; + explicit?: boolean; + isPlaying?: boolean; + subtitle?: string; + leading?: ReactNode; + trailing?: ReactNode; + onAdd?: () => void; + /** Swipe right adds to queue. Only set for single tracks. */ + onSwipeAdd?: () => void; + onPress?: () => void; + onLongPress?: () => void; +} + +export function MediaRow({ name, artUrl, explicit, isPlaying, subtitle, leading, trailing, onAdd, onSwipeAdd, onPress, onLongPress }: MediaRowProps) { + const row = ( + [styles.row, isPlaying && styles.rowPlaying, pressed && styles.rowPressed]} + > + {leading} + + + + + {name} + + {explicit ? : null} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {trailing} + {onAdd ? : null} + + ); + return onSwipeAdd ? {row} : row; +} + +// ─── MediaCard ─────────────────────────────────────────────────────────────── + +export interface MediaCardProps { + name: string; + sub?: string; + artUrl?: string | null; + circular?: boolean; + size?: number; + explicit?: boolean; + onPress?: () => void; + onLongPress?: () => void; + onAdd?: () => void; + /** Replaces the art (used by PlaylistCard's coloured tile). */ + artSlot?: ReactNode; +} + +export function MediaCard({ name, sub, artUrl, circular, size = 140, explicit, onPress, onLongPress, onAdd, artSlot }: MediaCardProps) { + return ( + [{ width: size }, pressed && { opacity: 0.7 }]} + > + + {artSlot ?? } + {onAdd ? ( + + + + ) : null} + + + + {name} + + {explicit ? : null} + + {sub ? ( + + {sub} + + ) : null} + + ); +} + +// ─── ArtistCircle ──────────────────────────────────────────────────────────── + +/** Artist items rarely carry an image, so (like desktop) fetch it from the artist endpoint. */ +function useItemArtistArt(item: SonosItem): string | null { + const rid = (item.resource?.id ?? (typeof item.id === 'object' ? item.id : undefined)) as SonosItemId | undefined; + const { data } = useArtistImage(rid?.objectId, rid?.serviceId, rid?.accountId, item.resource?.defaults as string | undefined); + return data ?? getItemArt(item); +} + +export function ArtistCircle({ + artist, + size = 96, + onPress, + onLongPress, +}: { + artist: SonosItem; + size?: number; + onPress?: (item: SonosItem) => void; + onLongPress?: (item: SonosItem) => void; +}) { + const art = useItemArtistArt(artist); + return ( + onPress(artist) : undefined} + onLongPress={onLongPress ? () => onLongPress(artist) : undefined} + /> + ); +} + +// ─── PlaylistCard (TrueTunes custom playlists) ─────────────────────────────── + +export function PlaylistCard({ + pl, + displayName, + size = 140, + onPress, + onLongPress, +}: { + pl: PlaylistMeta; + displayName?: string | null; + size?: number; + onPress: () => void; + onLongPress?: () => void; +}) { + const meta = + `${pl.trackCount} track${pl.trackCount !== 1 ? 's' : ''}` + + (displayName !== undefined && pl.owner !== displayName ? ` · ${pl.owner}` : '') + + (!pl.isPublic ? ' · Private' : ''); + const tile = pl.imageUrl ? undefined : ( + + ); + return ; +} + +/** The desktop gradient tile: the CSS linear-gradient has no RN equivalent without a native dep, + * so we use a flat colour parsed from the same hue hash. */ +function PlaylistTile({ pl, size }: { pl: PlaylistMeta; size: number }) { + const bg = pl.isFavourites ? 'hsl(340,60%,30%)' : flatColour(pl.name); + return ( + + + {pl.isFavourites ? '❤️' : (pl.name[0] ?? '?').toUpperCase()} + + + ); +} + +function flatColour(name: string): string { + // getPlaylistColor returns "linear-gradient(135deg, hsl(H,45%,28%), ...)": reuse its first stop. + const m = /hsl\([^)]+\)/.exec(getPlaylistColor(name)); + return m ? m[0] : colors.bg1; +} + +// ─── CardRow ───────────────────────────────────────────────────────────────── + +export function CardRow({ + items, + isLoading, + onOpen, + onAdd, + onLongPress, + cardSize = 140, +}: { + items: SonosItem[]; + isLoading?: boolean; + onOpen: (item: SonosItem) => void; + onAdd?: (item: SonosItem) => void; + onLongPress?: (item: SonosItem) => void; + cardSize?: number; +}) { + if (isLoading) { + return ( + + + + ); + } + if (!items.length) return null; + return ( + `${getName(item)}-${i}`} + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.cardRowContent} + renderItem={({ item }) => + isArtist(item) ? ( + + ) : ( + onOpen(item)} + onLongPress={onLongPress ? () => onLongPress(item) : undefined} + onAdd={onAdd ? () => onAdd(item) : undefined} + /> + ) + } + /> + ); +} + +const styles = StyleSheet.create({ + explicit: { + marginLeft: 6, + width: 14, + height: 14, + borderRadius: 3, + backgroundColor: colors.text3, + alignItems: 'center', + justifyContent: 'center', + }, + explicitText: { color: colors.bg, fontSize: 9, fontWeight: '800' }, + addBtn: { alignItems: 'center', justifyContent: 'center', borderRadius: radius.pill, backgroundColor: colors.bg2 }, + row: { + minHeight: 56, + flexDirection: 'row', + alignItems: 'center', + gap: space.md, + paddingHorizontal: space.lg, + paddingVertical: space.xs, + }, + rowPlaying: { backgroundColor: colors.bg2 }, + rowPressed: { backgroundColor: colors.bg2 }, + rowText: { flex: 1, minWidth: 0 }, + nameLine: { flexDirection: 'row', alignItems: 'center' }, + flexShrink: { flexShrink: 1 }, + cardName: { flexShrink: 1, fontSize: 14, fontWeight: '600' }, + cardAdd: { position: 'absolute', right: 4, bottom: 4 }, + tile: { borderRadius: radius.md, alignItems: 'center', justifyContent: 'center' }, + cardRow: { alignItems: 'center', justifyContent: 'center' }, + cardRowContent: { paddingHorizontal: space.lg, gap: space.md }, +}); diff --git a/apps/mobile/src/ui/media/index.ts b/apps/mobile/src/ui/media/index.ts new file mode 100644 index 0000000..0edf73a --- /dev/null +++ b/apps/mobile/src/ui/media/index.ts @@ -0,0 +1,5 @@ +export * from './components'; +export * from './ActionSheet'; +export * from './routes'; +export * from './itemLogic'; +export * from './useDebouncedValue'; diff --git a/apps/mobile/src/ui/media/itemLogic.ts b/apps/mobile/src/ui/media/itemLogic.ts new file mode 100644 index 0000000..f743e5c --- /dev/null +++ b/apps/mobile/src/ui/media/itemLogic.ts @@ -0,0 +1,59 @@ +// Pure helpers behind the media components (no React Native imports so vitest can cover them). +import { getItemArt, getName, isAlbum, isArtist, isPlaylist, isTrack } from '../../logic/lib/itemHelpers'; +import type { SonosArtist, SonosItem, SonosItemId } from '../../logic/types/sonos'; + +/** Port of desktop SearchResults.buildTrackPayload: SonosItem -> playlist track document. */ +export function toPlaylistTrack(item: SonosItem, addedBy = ''): PlaylistTrack { + const rid = (typeof item.id === 'object' ? item.id : item.resource?.id) as SonosItemId | undefined; + const artist = + typeof item.artist === 'string' + ? item.artist + : ((item.artist as SonosArtist | undefined)?.name ?? item.artists?.[0]?.name ?? ''); + const albumName = typeof item.album === 'string' ? item.album : item.album?.name; + return { + uri: rid?.objectId ?? '', + trackName: getName(item), + artist, + albumName, + imageUrl: getItemArt(item), + serviceId: rid?.serviceId ?? '', + accountId: rid?.accountId ?? '', + addedBy, + addedAt: 0, + }; +} + +export interface SearchSections { + artists: SonosItem[]; + albums: SonosItem[]; + tracks: SonosItem[]; + playlists: SonosItem[]; + others: SonosItem[]; +} + +/** Desktop SearchResults section split. Order inside each section follows the API order. */ +export function shapeSearchSections(results: SonosItem[]): SearchSections { + const out: SearchSections = { artists: [], albums: [], tracks: [], playlists: [], others: [] }; + for (const r of results) { + if (isArtist(r)) out.artists.push(r); + else if (isAlbum(r)) out.albums.push(r); + else if (isTrack(r)) out.tracks.push(r); + else if (isPlaylist(r)) out.playlists.push(r); + else out.others.push(r); + } + return out; +} + +export type ItemActionKey = 'playNext' | 'addToQueue' | 'goToAlbum' | 'goToArtist' | 'favourite' | 'addToPlaylist'; + +/** Which menu actions apply. Desktop: play next / add to queue / favourites / playlists (tracks only). */ +export function actionsForItem( + item: SonosItem, + ctx: { hasAlbumRoute: boolean; hasArtistRoute: boolean; canPlaylist: boolean }, +): ItemActionKey[] { + const keys: ItemActionKey[] = ['playNext', 'addToQueue']; + if (ctx.hasAlbumRoute && !isAlbum(item) && !isArtist(item)) keys.push('goToAlbum'); + if (ctx.hasArtistRoute && !isArtist(item)) keys.push('goToArtist'); + if (ctx.canPlaylist && isTrack(item)) keys.push('favourite', 'addToPlaylist'); + return keys; +} diff --git a/apps/mobile/src/ui/media/routes.ts b/apps/mobile/src/ui/media/routes.ts new file mode 100644 index 0000000..e7b34d3 --- /dev/null +++ b/apps/mobile/src/ui/media/routes.ts @@ -0,0 +1,110 @@ +// Route conventions for detail screens. The browse UI (this folder, Home, Search) builds hrefs with +// these helpers; the detail-screen agent implements the screens against the SAME params. +// +// Detail routes (all params are URL-encoded strings; optional ones are omitted when unknown): +// /album/[id] id = albumId (objectId). query: serviceId, accountId, defaults?, name? +// /artist/[id] id = artistId (objectId). query: serviceId, accountId, defaults?, name? +// /container/[id] id = containerId (objectId). query: serviceId, accountId, defaults?, name? +// (ITEM_AUDIO / ITEM_STUDIO / ITEM_PLAYLIST / CONTAINER / SECTION browse items) +// /playlist/[id] id = TrueTunes custom playlist id (PlaylistMeta.id, e.g. "fav-"). No query. +// /profile/[id] id = userId (display name). No query. +// `defaults` is the opaque base64 blob from item.resource.defaults, passed through untouched. +// `name` is only a header hint shown while loading. accountId has the "sn_" prefix already stripped +// (see resolveAlbumParams). Sonos PLAYLIST items open as /album/[id] like desktop's useOpenItem. +import type { NavigateFn } from '../../logic/hooks/useOpenItem'; +import { + getName, + isArtist, + isContainer, + resolveAlbumParams, + resolveArtistParams, +} from '../../logic/lib/itemHelpers'; +import type { SonosAlbum, SonosArtist, SonosItem, SonosItemId } from '../../logic/types/sonos'; + +export type DetailKind = 'album' | 'artist' | 'container'; + +interface RouteArgs { + id: string | undefined; + serviceId?: string; + accountId?: string; + defaults?: string; + name?: string; +} + +export function buildDetailRoute(kind: DetailKind, a: RouteArgs): string | null { + if (!a.id) return null; + const q: string[] = []; + const add = (k: string, v?: string) => { + if (v) q.push(`${k}=${encodeURIComponent(v)}`); + }; + add('serviceId', a.serviceId); + add('accountId', a.accountId); + add('defaults', a.defaults); + add('name', a.name); + return `/${kind}/${encodeURIComponent(a.id)}${q.length ? `?${q.join('&')}` : ''}`; +} + +/** Where tapping a Sonos item goes (mirrors desktop useOpenItem). Null if it has no id. */ +export function routeForItem(item: SonosItem): string | null { + const name = getName(item); + const hint = name === '(unknown)' ? undefined : name; + if (isArtist(item)) { + const p = resolveArtistParams(item); + return buildDetailRoute('artist', { id: p.artistId, serviceId: p.serviceId, accountId: p.accountId, defaults: p.defaults, name: hint }); + } + const p = resolveAlbumParams(item); + return buildDetailRoute(isContainer(item) ? 'container' : 'album', { + id: p.albumId, + serviceId: p.serviceId, + accountId: p.accountId, + defaults: p.defaults, + name: hint, + }); +} + +export const routeForPlaylist = (id: string) => `/playlist/${encodeURIComponent(id)}`; +export const routeForProfile = (userId: string) => `/profile/${encodeURIComponent(userId)}`; + +/** "Go to album" for a track (or anything carrying an album reference). */ +export function albumRouteForItem(item: SonosItem): string | null { + const album = ((typeof item.album === 'object' ? item.album : undefined) ?? + (typeof item.track?.album === 'object' ? item.track.album : undefined)) as SonosAlbum | undefined; + const rid = album?.id as SonosItemId | undefined; + if (!rid?.objectId) return null; + return buildDetailRoute('album', { + id: rid.objectId, + serviceId: rid.serviceId, + accountId: rid.accountId?.replace(/^sn_/, ''), + name: album?.name, + }); +} + +/** "Go to artist": first credited artist that has an id, else the artist encoded in `defaults`. */ +export function artistRouteForItem(item: SonosItem): string | null { + if (isArtist(item)) return routeForItem(item); + const candidates: unknown[] = [...(item.artists ?? []), item.artist, item.primaryArtist, item.track?.artist]; + for (const c of candidates) { + const a = c as SonosArtist | undefined; + if (a && typeof a === 'object' && a.id?.objectId) { + return buildDetailRoute('artist', { + id: a.id.objectId, + serviceId: a.id.serviceId, + accountId: a.id.accountId?.replace(/^sn_/, ''), + name: a.name, + }); + } + } + const p = resolveArtistParams(item); + return buildDetailRoute('artist', { id: p.artistId, serviceId: p.serviceId, accountId: p.accountId, name: p.name }); +} + +/** + * Adapter for useOpenItem / useResolveAndOpen, which call navigate(path, { state: { item } }) with + * a path that has no query params. We rebuild the href from the item so params are not lost. + */ +export function makeNavigate(push: (href: string) => void): NavigateFn { + return (to, options) => { + const item = (options?.state as { item?: SonosItem } | undefined)?.item; + push((item && routeForItem(item)) ?? to); + }; +} diff --git a/apps/mobile/src/ui/media/useDebouncedValue.ts b/apps/mobile/src/ui/media/useDebouncedValue.ts new file mode 100644 index 0000000..24d2ffc --- /dev/null +++ b/apps/mobile/src/ui/media/useDebouncedValue.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from 'react'; + +/** Returns `value` after it has stopped changing for `delayMs`. */ +export function useDebouncedValue(value: T, delayMs = 350): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const t = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(t); + }, [value, delayMs]); + return debounced; +} diff --git a/apps/mobile/src/ui/media/useRecentSearches.ts b/apps/mobile/src/ui/media/useRecentSearches.ts new file mode 100644 index 0000000..a3d5311 --- /dev/null +++ b/apps/mobile/src/ui/media/useRecentSearches.ts @@ -0,0 +1,23 @@ +import { useCallback, useEffect, useState } from 'react'; +import { addRecent, clearRecent, loadRecent, removeRecent } from '../../logic/lib/recentSearches'; +import { secureKeyValueStore } from '../../platform/secureStore'; + +/** Recent search queries, persisted in the secure key/value store. */ +export function useRecentSearches() { + const [recent, setRecent] = useState([]); + + useEffect(() => { + let live = true; + void loadRecent(secureKeyValueStore).then((l) => { + if (live) setRecent(l); + }); + return () => { + live = false; + }; + }, []); + + const add = useCallback((q: string) => void addRecent(secureKeyValueStore, q).then(setRecent), []); + const remove = useCallback((q: string) => void removeRecent(secureKeyValueStore, q).then(setRecent), []); + const clear = useCallback(() => void clearRecent(secureKeyValueStore).then(setRecent), []); + return { recent, add, remove, clear }; +} diff --git a/apps/mobile/src/ui/nowplaying/TintBackground.tsx b/apps/mobile/src/ui/nowplaying/TintBackground.tsx new file mode 100644 index 0000000..46aaa8b --- /dev/null +++ b/apps/mobile/src/ui/nowplaying/TintBackground.tsx @@ -0,0 +1,35 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import { useEffect, useRef, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import { colors } from '../theme'; +import { rgba } from './colorMath'; + +/** Soft vertical gradient from the artwork colour into the bg; cross-fades when the colour changes. */ +export function TintBackground({ color }: { color: string | null }) { + const [layers, setLayers] = useState<{ prev: string | null; cur: string | null }>({ prev: null, cur: color }); + const shown = useRef(color); + const opacity = useSharedValue(1); + + useEffect(() => { + if (shown.current === color) return; + setLayers({ prev: shown.current, cur: color }); + shown.current = color; + opacity.value = 0; + opacity.value = withTiming(1, { duration: 600 }); + }, [color, opacity]); + + const curStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + const prevStyle = useAnimatedStyle(() => ({ opacity: 1 - opacity.value })); + + return ( + + {layers.prev ? : null} + {layers.cur ? : null} + + ); +} + +function Gradient({ color }: { color: string }) { + return ; +} diff --git a/apps/mobile/src/ui/nowplaying/colorMath.ts b/apps/mobile/src/ui/nowplaying/colorMath.ts new file mode 100644 index 0000000..4d029c6 --- /dev/null +++ b/apps/mobile/src/ui/nowplaying/colorMath.ts @@ -0,0 +1,42 @@ +/** Pure colour maths for the Now Playing tint. No React Native imports (unit-tested in node). */ + +export function hexToRgb(hex: string | null | undefined): [number, number, number] | null { + if (!hex) return null; + let h = hex.trim().replace(/^#/, ''); + if (h.length === 3) h = h.split('').map((c) => c + c).join(''); + if (h.length === 8) h = h.slice(0, 6); // drop alpha + if (!/^[0-9a-fA-F]{6}$/.test(h)) return null; + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} + +/** Relative luminance 0..1 (Rec. 709 on gamma-encoded values; good enough for a guard). */ +export function luminance(r: number, g: number, b: number): number { + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; +} + +/** Near-black / near-white colours make a useless tint. */ +export function isTintable(r: number, g: number, b: number): boolean { + const l = luminance(r, g, b); + return l >= 0.1 && l <= 0.9; +} + +/** "#rrggbb" -> "r, g, b", or null when unparseable or near-black/near-white. */ +export function hexToTintRgb(hex: string | null | undefined): string | null { + const rgb = hexToRgb(hex); + if (!rgb || !isTintable(...rgb)) return null; + return rgb.join(', '); +} + +/** Picks vibrant, else dominant, else average from an Android image-colors result. */ +export function pickTintFromColors(c: { vibrant?: string; dominant?: string; average?: string } | null | undefined): string | null { + if (!c) return null; + for (const hex of [c.vibrant, c.dominant, c.average]) { + const out = hexToTintRgb(hex); + if (out) return out; + } + return null; +} + +export function rgba(rgb: string, alpha: number): string { + return `rgba(${rgb}, ${alpha})`; +} diff --git a/apps/mobile/src/ui/nowplaying/favouriteTrack.ts b/apps/mobile/src/ui/nowplaying/favouriteTrack.ts new file mode 100644 index 0000000..f14aaeb --- /dev/null +++ b/apps/mobile/src/ui/nowplaying/favouriteTrack.ts @@ -0,0 +1,45 @@ +/** Pure decision logic for the Now Playing favourite heart. No React / RN imports. */ + +export interface FavSource { + uri: string | null | undefined; + trackName: string | undefined; + artist: string | undefined; + albumName?: string | undefined; + imageUrl?: string | null; + serviceId: string | null | undefined; + accountId: string | null | undefined; +} + +export type FavouriteDecision = 'noop' | 'prompt-name' | 'add' | 'remove'; + +export function isTrackFavourited(doc: { tracks: { uri: string }[] } | null | undefined, uri: string | null | undefined): boolean { + return !!(uri && doc?.tracks.some((t) => t.uri === uri)); +} + +/** displayName: undefined = still loading, null = unset (prompt), string = ready. */ +export function decideFavourite(displayName: string | null | undefined, uri: string | null | undefined, isFavourited: boolean): FavouriteDecision { + if (displayName === undefined || !uri) return 'noop'; + if (displayName === null || displayName === '') return 'prompt-name'; + return isFavourited ? 'remove' : 'add'; +} + +export function buildFavouriteTrack(src: FavSource, displayName: string, now = Date.now()): PlaylistTrack { + return { + uri: src.uri ?? '', + trackName: src.trackName ?? '', + artist: src.artist ?? '', + albumName: src.albumName, + imageUrl: src.imageUrl ?? null, + serviceId: src.serviceId ?? '', + accountId: src.accountId ?? '', + addedBy: displayName, + addedAt: now, + }; +} + +/** Returns the optimistic playlist doc after adding/removing. */ +export function applyFavourite(doc: T, action: 'add' | 'remove', track: PlaylistTrack): T { + return action === 'remove' + ? { ...doc, tracks: doc.tracks.filter((t) => t.uri !== track.uri) } + : { ...doc, tracks: [...doc.tracks, track] }; +} diff --git a/apps/mobile/src/ui/nowplaying/registerColorExtractor.ts b/apps/mobile/src/ui/nowplaying/registerColorExtractor.ts new file mode 100644 index 0000000..8c86e87 --- /dev/null +++ b/apps/mobile/src/ui/nowplaying/registerColorExtractor.ts @@ -0,0 +1,20 @@ +import { getColors } from 'react-native-image-colors'; +import { setColorExtractor } from '../../logic/hooks/useDominantColor'; +import { pickTintFromColors } from './colorMath'; + +let registered = false; + +/** Registers the native (Android Palette) colour extractor for useDominantColor. Idempotent. */ +export function registerColorExtractor(): void { + if (registered) return; + registered = true; + setColorExtractor(async (uri) => { + try { + const res = await getColors(uri, { fallback: '#1c1c1e', cache: true, key: uri }); + if (res.platform !== 'android') return null; + return pickTintFromColors(res); + } catch { + return null; + } + }); +} diff --git a/apps/mobile/src/ui/nowplaying/useFavouriteTrack.ts b/apps/mobile/src/ui/nowplaying/useFavouriteTrack.ts new file mode 100644 index 0000000..3bdc857 --- /dev/null +++ b/apps/mobile/src/ui/nowplaying/useFavouriteTrack.ts @@ -0,0 +1,46 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { router } from 'expo-router'; +import { useCallback, useState } from 'react'; +import { favouritesId, usePlaylist } from '../../logic/hooks/usePlaylists'; +import { useShowToast } from '../Toast'; +import { applyFavourite, buildFavouriteTrack, decideFavourite, isTrackFavourited } from './favouriteTrack'; +import type { FavSource } from './favouriteTrack'; + +/** + * Now Playing heart. Shares the ['playlist', favId] / ['playlists','owned',name] query keys with + * the ActionSheet's "Add to Favourites" and desktop useFavourite, so all stay consistent. + * displayName: undefined = loading, null = unset (tapping opens /display-name). + */ +export function useFavouriteTrack(displayName: string | null | undefined, track: FavSource) { + const queryClient = useQueryClient(); + const showToast = useShowToast(); + const favId = displayName ? favouritesId(displayName) : null; + const { data: playlist } = usePlaylist(favId ?? undefined); + const [pending, setPending] = useState(false); + const isFavourited = isTrackFavourited(playlist, track.uri); + + const toggle = useCallback(async () => { + const decision = decideFavourite(displayName, track.uri, isFavourited); + if (decision === 'prompt-name') { router.push('/display-name'); return; } + if (decision === 'noop' || !favId || !displayName || !track.uri || pending) return; + + const key = ['playlist', favId]; + const snapshot = queryClient.getQueryData(key); + const entry = buildFavouriteTrack(track, displayName); + if (snapshot) queryClient.setQueryData(key, applyFavourite(snapshot, decision, entry)); + setPending(true); + try { + if (decision === 'remove') await window.sonos.removeTrackFromPlaylist(favId, track.uri); + else await window.sonos.addTrackToPlaylist(favId, entry); + queryClient.invalidateQueries({ queryKey: key }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + } catch { + if (snapshot) queryClient.setQueryData(key, snapshot); + showToast(decision === 'remove' ? 'Failed to remove from Favourites' : 'Failed to add to Favourites', 'error'); + } finally { + setPending(false); + } + }, [displayName, track, isFavourited, favId, pending, queryClient, showToast]); + + return { isFavourited, toggle }; +} diff --git a/apps/mobile/src/ui/queue/DjSection.tsx b/apps/mobile/src/ui/queue/DjSection.tsx new file mode 100644 index 0000000..02cfead --- /dev/null +++ b/apps/mobile/src/ui/queue/DjSection.tsx @@ -0,0 +1,48 @@ +import { Disc3 } from 'lucide-react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useAutoplayState } from '../../logic/AutoplayContext'; +import { Art } from '../Art'; +import { colors, space, type } from '../theme'; +import { djBreakLabel } from './queueLogic'; + +/** DJ break + upcoming picks. Not queue rows: no drag, select or skip. Renders nothing without autoplay. */ +export function DjSection() { + const autoplay = useAutoplayState(); + if (!autoplay) return null; + const { enabled, upcoming, setEnabled } = autoplay; + return ( + + + + setEnabled(!enabled)}> + + {djBreakLabel(enabled)} + + + + {upcoming.length === 0 ? ( + Nothing to suggest just yet. + ) : ( + upcoming.map((t) => ( + + + + {t.trackName} + {t.artist} + {!!t.because && {t.because}} + + + )) + )} + + ); +} + +const styles = StyleSheet.create({ + break: { flexDirection: 'row', alignItems: 'center', gap: space.sm, paddingHorizontal: space.lg, paddingVertical: space.md }, + line: { flex: 1, height: StyleSheet.hairlineWidth, backgroundColor: colors.border2 }, + label: { flexDirection: 'row', alignItems: 'center', gap: space.xs, padding: space.xs }, + row: { flexDirection: 'row', alignItems: 'center', gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.sm }, + text: { flex: 1, gap: 1 }, + empty: { textAlign: 'center', padding: space.md }, +}); diff --git a/apps/mobile/src/ui/queue/QueueRow.tsx b/apps/mobile/src/ui/queue/QueueRow.tsx new file mode 100644 index 0000000..dfaf87a --- /dev/null +++ b/apps/mobile/src/ui/queue/QueueRow.tsx @@ -0,0 +1,103 @@ +import { GripVertical, Check } from 'lucide-react-native'; +import { memo } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Art } from '../Art'; +import { SwipeRow } from '../SwipeRow'; +import { colors, radius, space, type } from '../theme'; +import { useQueueTrack } from '../../logic/hooks/useQueueTrack'; +import { ROW_HEIGHT } from './queueLogic'; +import type { NormalizedQueueItem } from '../../logic/types/provider'; + +interface Props { + item: NormalizedQueueItem; + index: number; + isPlaying: boolean; + isSelected: boolean; + selectMode: boolean; + isDragging: boolean; + swipeEnabled: boolean; + addedBy?: string; + onPress: (index: number) => void; + onLongPress: (index: number) => void; + onRemove: (index: number) => void; + onDragHandle: () => void; +} + +function QueueRowImpl({ + item, index, isPlaying, isSelected, selectMode, isDragging, swipeEnabled, addedBy, + onPress, onLongPress, onRemove, onDragHandle, +}: Props) { + const t = item.track; + // Queue resources carry only ids; art and artist come from track details, as on desktop. + const { artUrl, artist } = useQueueTrack(item, null); + const bg = [isPlaying && styles.playing, isSelected && styles.selected, isDragging && styles.dragging]; + // Swipe covers the row body only; the grip sits outside it so drag and swipe never compete. + return ( + + onRemove(index)}> + onPress(index)} + onLongPress={() => onLongPress(index)} + delayLongPress={350} + android_ripple={{ color: colors.bg2 }} + style={[styles.row, ...bg]} + > + {selectMode && ( + + {isSelected && } + + )} + + + + + {t.title} + + {t.isExplicit && ( + + E + + )} + + {!!artist && {artist}} + {!!addedBy && added by {addedBy}} + + + + {!selectMode && ( + + + + )} + + ); +} + +export const QueueRow = memo(QueueRowImpl); + +const styles = StyleSheet.create({ + outer: { flexDirection: 'row', alignItems: 'stretch', backgroundColor: colors.bg, height: ROW_HEIGHT }, + flex: { flex: 1 }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: space.md, + paddingLeft: space.lg, + paddingVertical: space.sm, + }, + playing: { backgroundColor: 'rgba(42,109,244,0.16)' }, + selected: { backgroundColor: 'rgba(42,109,244,0.28)' }, + dragging: { backgroundColor: colors.bg1, elevation: 8 }, + text: { flex: 1, gap: 1 }, + titleRow: { flexDirection: 'row', alignItems: 'center', gap: space.xs }, + title: { flexShrink: 1 }, + playingText: { color: colors.accent, fontWeight: '600' }, + explicit: { backgroundColor: colors.text3, borderRadius: 3, paddingHorizontal: 4 }, + explicitText: { color: colors.bg, fontSize: 10, fontWeight: '700' }, + handle: { paddingHorizontal: space.lg, alignItems: 'center', justifyContent: 'center' }, + check: { + width: 22, height: 22, borderRadius: radius.pill, borderWidth: 2, borderColor: colors.border2, + alignItems: 'center', justifyContent: 'center', + }, + checkOn: { backgroundColor: colors.accent, borderColor: colors.accent }, +}); diff --git a/apps/mobile/src/ui/queue/SelectionBar.tsx b/apps/mobile/src/ui/queue/SelectionBar.tsx new file mode 100644 index 0000000..4230e28 --- /dev/null +++ b/apps/mobile/src/ui/queue/SelectionBar.tsx @@ -0,0 +1,32 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { colors, radius, space, type } from '../theme'; +import { selectionLabel } from './queueLogic'; + +export function SelectionBar({ + count, onRemove, onClear, onCancel, +}: { count: number; onRemove: () => void; onClear: () => void; onCancel: () => void }) { + return ( + + {selectionLabel(count)} + Done + Clear + + Remove + + + ); +} + +const styles = StyleSheet.create({ + bar: { + flexDirection: 'row', alignItems: 'center', gap: space.sm, padding: space.md, + backgroundColor: colors.bg1, borderTopColor: colors.border, borderTopWidth: StyleSheet.hairlineWidth, + }, + label: { flex: 1 }, + btn: { paddingHorizontal: space.md, paddingVertical: space.sm, borderRadius: radius.md }, + remove: { backgroundColor: colors.danger }, +}); diff --git a/apps/mobile/src/ui/queue/queueLogic.ts b/apps/mobile/src/ui/queue/queueLogic.ts new file mode 100644 index 0000000..4e826e7 --- /dev/null +++ b/apps/mobile/src/ui/queue/queueLogic.ts @@ -0,0 +1,126 @@ +// Pure helpers for the Queue screen (no React Native imports so vitest can load them). +import { attributionContentKey } from '../../logic/hooks/useAttribution'; +import { applyReorderLocally } from '../../logic/lib/queueHelpers'; +import type { NormalizedQueueItem, NormalizedTrack } from '../../logic/types/provider'; + +/** Match attribution by objectId first, then title+artist content key (desktop resolveAttribution). */ +export function resolveAttribution( + map: Record, + track: Pick, +): { user: string } | undefined { + const byId = map[track.id ?? '']; + if (byId) return byId; + const ck = attributionContentKey(track.title, track.artist); + return ck ? map[ck] : undefined; +} + +/** Desktop marks the playing row when Number(queueItemId) === index + 1, else falls back to objectId. */ +export function isPlayingRow( + index: number, + trackId: string, + queueItemId: string | null, + currentObjectId: string | null, +): boolean { + if (queueItemId !== null) return Number(queueItemId) === index + 1; + return !!currentObjectId && trackId === currentObjectId; +} + +/** skipToTrack takes a 1-based track number. */ +export const trackNumberForIndex = (index: number) => index + 1; + +/** Toggle membership, returning a new set. */ +export function toggleSelected(selected: ReadonlySet, index: number): Set { + const next = new Set(selected); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; +} + +/** Toggle a track in multi-select; deselecting the last one exits select mode. */ +export function toggleSelection( + selected: ReadonlySet, + index: number, +): { selected: Set; selectMode: boolean } { + const next = toggleSelected(selected, index); + return { selected: next, selectMode: next.size > 0 }; +} + +/** + * DraggableFlatList reports the item's final position (`to`); the server API and + * applyReorderLocally want the insert-before index in the ORIGINAL list. + */ +export function insertBeforeIndex(from: number, to: number): number { + return to > from ? to + 1 : to; +} + +/** Optimistic single-item reorder using the desktop math. Returns the new list and the API args. */ +export function planSingleReorder(items: T[], from: number, to: number) { + const toIndex = insertBeforeIndex(from, to); + return { + next: applyReorderLocally(items, [from], toIndex), + fromIndices: [from], + toIndex, + queueLength: items.length, + }; +} + +/** Stable keys across reorders: track id plus occurrence count for duplicates. */ +export function buildKeys(items: NormalizedQueueItem[]): string[] { + const seen = new Map(); + return items.map((it) => { + const n = (seen.get(it.track.id) ?? 0) + 1; + seen.set(it.track.id, n); + return `${it.track.id}#${n}`; + }); +} + +export function removeIndices(items: T[], indices: ReadonlySet): T[] { + return items.filter((_, i) => !indices.has(i)); +} + +export function selectionLabel(count: number): string { + return `${count} track${count === 1 ? '' : 's'} selected`; +} + +export const djBreakLabel = (enabled: boolean) => (enabled ? 'DJ takes over' : 'DJ paused'); + +// ── Scroll-to-now-playing ─────────────────────────────────────────────────── + +/** Rows have a fixed height so scroll offsets are exact and don't shift when an "added by" line loads. */ +export const ROW_HEIGHT = 72; + +export type PlayingPosition = 'none' | 'visible' | 'above' | 'below'; + +/** Index of the playing row, or -1 when nothing in the list is playing. */ +export function findPlayingIndex( + items: ReadonlyArray<{ track: { id: string } }>, + queueItemId: string | null, + currentObjectId: string | null, +): number { + return items.findIndex((it, i) => isPlayingRow(i, it.track.id, queueItemId, currentObjectId)); +} + +/** Where the playing row is relative to the visible window; 'above'/'below' mean fully off-screen. */ +export function playingPosition( + index: number, + scrollY: number, + viewHeight: number, + rowHeight: number = ROW_HEIGHT, +): PlayingPosition { + if (index < 0 || viewHeight <= 0) return 'none'; + const top = index * rowHeight; + const bottom = top + rowHeight; + if (bottom <= scrollY) return 'above'; + if (top >= scrollY + viewHeight) return 'below'; + return 'visible'; +} + +/** Scroll offset that puts the playing row near the top, with a row of context above it. */ +export function scrollOffsetForPlaying(playingIndex: number, contextRows = 1, rowHeight: number = ROW_HEIGHT): number { + return Math.max(0, playingIndex - contextRows) * rowHeight; +} + +/** Follow the playing track only while the user hasn't scrolled away, and only when it actually moved. */ +export function shouldAutoFollow(following: boolean, previousIndex: number, nextIndex: number): boolean { + return following && nextIndex >= 0 && nextIndex !== previousIndex; +} diff --git a/apps/mobile/src/ui/social/common.tsx b/apps/mobile/src/ui/social/common.tsx new file mode 100644 index 0000000..647fcc4 --- /dev/null +++ b/apps/mobile/src/ui/social/common.tsx @@ -0,0 +1,92 @@ +import { router } from 'expo-router'; +import { ChevronLeft } from 'lucide-react-native'; +import type { ReactNode } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { avatarColor } from '../../logic/lib/socialFormat'; +import { Art } from '../Art'; +import { colors, radius, space, type } from '../theme'; + +/** Top bar with a back chevron, title, and optional right-side action. */ +export function ScreenHeader({ title, right, onBack, topInset = true }: { title: string; right?: ReactNode; onBack?: () => void; topInset?: boolean }) { + const insets = useSafeAreaInsets(); + return ( + + router.back())} accessibilityLabel="Back" style={styles.side}> + + + + {title} + + {right} + + ); +} + +/** Pill tab strip. */ +export function Segmented({ + options, + value, + onChange, +}: { + options: { value: T; label: string }[]; + value: T; + onChange: (v: T) => void; +}) { + return ( + + {options.map((o) => ( + onChange(o.value)} style={[styles.segBtn, o.value === value && styles.segActive]}> + {o.label} + + ))} + + ); +} + +/** User avatar: profile image if present, else a coloured initial. */ +export function UserAvatar({ name, uri, size }: { name: string; uri?: string | null; size: number }) { + if (uri) return ; + return ( + + {(name[0] ?? '?').toUpperCase()} + + ); +} + +export function SectionTitle({ children }: { children: ReactNode }) { + return {children}; +} + +export function Button({ label, onPress, disabled, danger }: { label: string; onPress: () => void; disabled?: boolean; danger?: boolean }) { + return ( + + {label} + + ); +} + +export const inputStyle = { + backgroundColor: colors.bg2, + color: colors.text, + borderRadius: radius.md, + paddingHorizontal: space.md, + paddingVertical: space.md, + fontSize: 16, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border2, +} as const; + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: space.md, paddingBottom: space.sm }, + side: { width: 56 }, + title: { flex: 1, textAlign: 'center' }, + seg: { flexDirection: 'row', gap: space.xs, backgroundColor: colors.bg1, borderRadius: radius.pill, padding: 3 }, + segBtn: { flex: 1, alignItems: 'center', paddingVertical: space.sm, borderRadius: radius.pill }, + segActive: { backgroundColor: colors.bg2 }, + btn: { backgroundColor: colors.accent, borderRadius: radius.md, paddingVertical: space.md, alignItems: 'center' }, +}); diff --git a/apps/mobile/src/ui/social/identity/IdentityPicker.tsx b/apps/mobile/src/ui/social/identity/IdentityPicker.tsx new file mode 100644 index 0000000..e5e5a6e --- /dev/null +++ b/apps/mobile/src/ui/social/identity/IdentityPicker.tsx @@ -0,0 +1,114 @@ +import { Check } from 'lucide-react-native'; +import { useMemo, useState } from 'react'; +import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { Button, UserAvatar, inputStyle } from '../common'; +import { colors, space, type } from '../../theme'; +import { FILTER_THRESHOLD, filterUsers, isCurrentIdentity, normaliseAvatarUrl, sortUsers } from './identityHelpers'; +import type { IdentityUser } from './identityHelpers'; + +interface Props { + users: IdentityUser[] | undefined; + loading: boolean; + error: boolean; + onRetry: () => void; + current: string | null | undefined; + saving: boolean; + onPick: (name: string) => void; +} + +/** Scrollable list of existing identities with an optional filter box. */ +export function IdentityList({ users, loading, error, onRetry, current, saving, onPick }: Props) { + const [filter, setFilter] = useState(''); + const sorted = useMemo(() => sortUsers(users ?? []), [users]); + const shown = useMemo(() => filterUsers(sorted, filter), [sorted, filter]); + + if (loading) return ; + if (error) { + return ( + + Could not load the list of people. +
    + - {status === 'loading' && ( -
    Loading releases…
    - )} + {status === 'loading' &&
    Loading releases…
    } - {status === 'error' && ( -
    Could not load release notes.
    - )} + {status === 'error' &&
    Could not load release notes.
    } - {status === 'ready' && releases.length === 0 && ( -
    No releases found.
    - )} + {status === 'ready' && releases.length === 0 &&
    No releases found.
    } {status === 'ready' && releases.length > 0 && (
    {releases.map((r, i) => { - const isCur = appVersion && - (r.tag_name === `v${appVersion}` || r.tag_name === appVersion); + const isCur = appVersion && (r.tag_name === `v${appVersion}` || r.tag_name === appVersion); return (
    - {selected.body?.trim() - ?
    - : No release notes. - } + {selected.body?.trim() ? ( +
    + ) : ( + No release notes. + )}
    )} diff --git a/apps/desktop/renderer/src/components/ContainerPanel.tsx b/apps/desktop/renderer/src/components/ContainerPanel.tsx index 1597bc2..03c564f 100644 --- a/apps/desktop/renderer/src/components/ContainerPanel.tsx +++ b/apps/desktop/renderer/src/components/ContainerPanel.tsx @@ -3,14 +3,7 @@ import { useImage } from '../hooks/useImage'; import { useContainerBrowse } from '../hooks/useContainerBrowse'; import { useOpenItem } from '../hooks/useOpenItem'; import { MediaCard } from './common/MediaCard'; -import { - getName, - browseSub, - getItemArt, - isProgram, - isTrack, - resolveAlbumParams, -} from '../lib/itemHelpers'; +import { getName, browseSub, getItemArt, isProgram, isTrack, resolveAlbumParams } from '../lib/itemHelpers'; import type { SonosItem } from '../types/sonos'; import styles from '../styles/ContainerPanel.module.css'; @@ -23,20 +16,25 @@ export function ContainerPanel({ onAddToQueue }: Props) { const item = (state as { item?: SonosItem } | null)?.item; const openItem = useOpenItem(); - const { albumId: containerId, serviceId, accountId, defaults } = item + const { + albumId: containerId, + serviceId, + accountId, + defaults, + } = item ? resolveAlbumParams(item) : { albumId: undefined, serviceId: undefined, accountId: undefined, defaults: undefined }; const { data, isLoading, error } = useContainerBrowse(containerId, serviceId, accountId, defaults); - const artUrl = item ? getItemArt(item) : null; + const artUrl = item ? getItemArt(item) : null; const cachedArt = useImage(artUrl); - const title = (item?.name ?? item?.title ?? '') as string; - const sub = (item as Record | undefined)?.['subtitle'] as string | undefined; + const title = (item?.name ?? item?.title ?? '') as string; + const sub = (item as Record | undefined)?.['subtitle'] as string | undefined; function openHandler(child: SonosItem) { if (isProgram(child)) return onAddToQueue(child); - if (isTrack(child)) return onAddToQueue(child); + if (isTrack(child)) return onAddToQueue(child); openItem(child); } diff --git a/apps/desktop/renderer/src/components/DiscoverPanel.tsx b/apps/desktop/renderer/src/components/DiscoverPanel.tsx index 2958dc0..8b1d3cc 100644 --- a/apps/desktop/renderer/src/components/DiscoverPanel.tsx +++ b/apps/desktop/renderer/src/components/DiscoverPanel.tsx @@ -44,11 +44,7 @@ function DjRow({ track, onAdd }: { track: DjRowTrack; onAdd: () => void }) { const art = useImage(track.imageUrl ?? null); return (
    - {art ? ( - - ) : ( -
    - )} + {art ? :
    }
    {track.trackName}
    {track.artist}
    @@ -83,16 +79,13 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) { users: selected.length ? selected : roomKey ? roomKey.split(',') : [], limit: 25, }), - [selected, roomKey], + [selected, roomKey] ); const { data, isLoading, isFetching, refetch } = useDjSet(opts); // Only browse the music service once the user actually asks for new material — // it costs one round trip per act. - const { discoveries, isLoading: loadingNew } = useDjDiscoveries( - data?.artists, - mode === 'discover', - ); + const { discoveries, isLoading: loadingNew } = useDjDiscoveries(data?.artists, mode === 'discover'); const tracks = mode === 'discover' @@ -110,9 +103,7 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) { const busy = isLoading || (mode === 'discover' && loadingNew); function toggle(userId: string) { - setSelected((prev) => - prev.includes(userId) ? prev.filter((u) => u !== userId) : [...prev, userId], - ); + setSelected((prev) => (prev.includes(userId) ? prev.filter((u) => u !== userId) : [...prev, userId])); } // Presence tells us who's actually in the building; anyone it can't vouch for @@ -152,8 +143,8 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) { Discover

    - Music for whoever's in the room, from what the office actually plays. Picks are - scored so the least keen listener still gets a say. + Music for whoever's in the room, from what the office actually plays. Picks are scored so the least keen + listener still gets a say.

    @@ -172,12 +163,7 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) { Favourites
    - @@ -198,36 +184,39 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) { {/* Split by where people actually are. A single flat list gave no clue which names were driving the picks, so the set looked arbitrary. */} - {chipGroups.map((group) => ( - group.users.length > 0 && ( -
    - {group.label} -
    - {group.users.map((u) => { - const active = selected.includes(u.userId); - const inRoom = selected.length === 0 && group.key === 'here'; - return ( - - ); - })} + {chipGroups.map( + (group) => + group.users.length > 0 && ( +
    + {group.label} +
    + {group.users.map((u) => { + const active = selected.includes(u.userId); + const inRoom = selected.length === 0 && group.key === 'here'; + return ( + + ); + })} +
    -
    - ) - ))} + ) + )} {selected.length === 0 && ( {presence?.basis === 'sensed' @@ -245,13 +234,9 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) {
    {busy && ( -
    - {mode === 'discover' ? 'Digging for something new…' : 'Reading the room…'} -
    - )} - {!busy && data?.error && ( -
    Couldn't build a set: {data.error}
    +
    {mode === 'discover' ? 'Digging for something new…' : 'Reading the room…'}
    )} + {!busy && data?.error &&
    Couldn't build a set: {data.error}
    } {!busy && !data?.error && tracks.length === 0 && (
    {mode === 'discover' @@ -260,11 +245,7 @@ export function DiscoverPanel({ onAddToQueue, displayName }: Props) {
    )} {tracks.map((track) => ( - onAddToQueue(track.item, -1, { attribute: false })} - /> + onAddToQueue(track.item, -1, { attribute: false })} /> ))}
    diff --git a/apps/desktop/renderer/src/components/DisplayNameModal.tsx b/apps/desktop/renderer/src/components/DisplayNameModal.tsx index 63af876..4a5387a 100644 --- a/apps/desktop/renderer/src/components/DisplayNameModal.tsx +++ b/apps/desktop/renderer/src/components/DisplayNameModal.tsx @@ -38,7 +38,10 @@ export function DisplayNameModal({ onSave, defaultName }: Props) { type="text" placeholder="Your name" value={value} - onChange={(e) => { setValue(e.target.value); setError(null); }} + onChange={(e) => { + setValue(e.target.value); + setError(null); + }} autoFocus maxLength={32} spellCheck={false} diff --git a/apps/desktop/renderer/src/components/FeedbackDialog.tsx b/apps/desktop/renderer/src/components/FeedbackDialog.tsx index 7dd36df..ff6973c 100644 --- a/apps/desktop/renderer/src/components/FeedbackDialog.tsx +++ b/apps/desktop/renderer/src/components/FeedbackDialog.tsx @@ -8,7 +8,7 @@ interface Props { type IssueType = 'bug' | 'feature'; const REPO = 'TrueNorthIT/TrueTunes'; -const PAT = import.meta.env.VITE_GITHUB_PAT as string | undefined; +const PAT = import.meta.env.VITE_GITHUB_PAT as string | undefined; async function createGitHubIssue(title: string, body: string, type: IssueType) { if (!PAT) throw new Error('VITE_GITHUB_PAT is not set'); @@ -23,23 +23,26 @@ async function createGitHubIssue(title: string, body: string, type: IssueType) { body: JSON.stringify({ title, body, labels: [label] }), }); if (!res.ok) { - const err = await res.json().catch(() => ({})) as { message?: string }; + const err = (await res.json().catch(() => ({}))) as { message?: string }; throw new Error(err.message ?? `GitHub API error ${res.status}`); } return (await res.json()) as { html_url: string }; } export function FeedbackDialog({ onClose }: Props) { - const [type, setType] = useState('bug'); - const [title, setTitle] = useState(''); - const [description, setDesc] = useState(''); - const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); - const [issueUrl, setIssueUrl] = useState(''); - const [errorMsg, setErrorMsg] = useState(''); + const [type, setType] = useState('bug'); + const [title, setTitle] = useState(''); + const [description, setDesc] = useState(''); + const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [issueUrl, setIssueUrl] = useState(''); + const [errorMsg, setErrorMsg] = useState(''); const [displayName, setDisplayName] = useState(null); useEffect(() => { - window.sonos.getDisplayName().then(setDisplayName).catch(() => null); + window.sonos + .getDisplayName() + .then(setDisplayName) + .catch(() => null); }, []); const canSubmit = title.trim().length > 0; @@ -67,19 +70,21 @@ export function FeedbackDialog({ onClose }: Props) { return (
    - + {status === 'success' ? (
    ✓

    Submitted!

    -

    - Your {type === 'bug' ? 'bug report' : 'feature request'} has been created. -

    +

    Your {type === 'bug' ? 'bug report' : 'feature request'} has been created.

    - +
    ) : ( <> @@ -127,14 +132,8 @@ export function FeedbackDialog({ onClose }: Props) { rows={5} disabled={status === 'loading'} /> - {status === 'error' && ( -

    {errorMsg}

    - )} - diff --git a/apps/desktop/renderer/src/components/HomePanel.tsx b/apps/desktop/renderer/src/components/HomePanel.tsx index ec4d965..9e4b300 100644 --- a/apps/desktop/renderer/src/components/HomePanel.tsx +++ b/apps/desktop/renderer/src/components/HomePanel.tsx @@ -60,9 +60,14 @@ function AlbumListItem({ item, onOpen, onAdd }: { item: SonosItem; onOpen: () =>
    + > + + +
    ); } @@ -72,10 +77,7 @@ function ArtistGridCell({ item, onOpen }: { item: SonosItem; onOpen: () => void return (
    - {art - ? - :
    ♪
    - } + {art ? :
    ♪
    }
    {getName(item)}
    @@ -158,7 +160,13 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName const [selectedUser, setSelectedUser] = useState(undefined); const [pickerOpen, setPickerOpen] = useState(false); const pickerRef = useRef(null); - const { artistItems, albumItems, availableUsers, currentUserId, isLoading: recentLoading } = useRecentlyPlayed(selectedUser); + const { + artistItems, + albumItems, + availableUsers, + currentUserId, + isLoading: recentLoading, + } = useRecentlyPlayed(selectedUser); // Default to current user once we know who they are useEffect(() => { @@ -222,7 +230,7 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName const hasRecent = !!selectedUser || recentLoading || artistItems.length > 0 || albumItems.length > 0; const recentEmpty = !!selectedUser && !recentLoading && artistItems.length === 0 && albumItems.length === 0; - const users = availableUsers.length > 0 ? availableUsers : (currentUserId ? [currentUserId] : []); + const users = availableUsers.length > 0 ? availableUsers : currentUserId ? [currentUserId] : []; const isPickerLocked = users.length > 1 && !hasCompletedToday; return ( @@ -240,7 +248,9 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName {(colleaguesLoading || colleagues.length > 0) && (
    -

    People

    +

    + People +

    {colleaguesLoading ? Array.from({ length: 5 }).map((_, i) => ( @@ -252,8 +262,7 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName user={u} onClick={() => navigate(`/profile/${encodeURIComponent(u.userId)}`)} /> - )) - } + ))}
    )} @@ -262,7 +271,9 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName

    Playlists

    - +
    {[...ownedPlaylists, ...joinedPlaylists] @@ -271,13 +282,13 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName return b.updatedAt - a.updatedAt; }) .map((pl) => ( - navigate(`/playlist/${pl.id}`)} - /> - ))} + navigate(`/playlist/${pl.id}`)} + /> + ))}
    )} @@ -287,14 +298,14 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName

    Recently Played — Last 7 Days - {selectedUser && ( - — - )} + {selectedUser && — } {selectedUser && (

    - {recentEmpty && ( -
    Nothing queued in the last 7 days
    - )} + {recentEmpty &&
    Nothing queued in the last 7 days
    } {recentLoading && (
    @@ -371,39 +386,42 @@ export function HomePanel({ isAuthed, onAddToQueue, ytm, ytmLoading, displayName

    Albums

    {albumItems.map((item, i) => ( - openItem(item)} onAdd={() => onAddToQueue(item)} /> + openItem(item)} + onAdd={() => onAddToQueue(item)} + /> ))}
    )} - {artistItems.length > 0 && (() => { - const noAlbums = albumItems.length === 0; - const numCols = Math.min(noAlbums ? 5 : 3, artistItems.length); - const numRows = Math.ceil(artistItems.length / numCols); - const gridHeight = noAlbums - ? numRows === 1 ? 200 : numRows * 80 - : albumItems.length * 62 - 2; - return ( -
    -

    Artists

    -
    - {artistItems.map((item, i) => ( - openItem(item)} /> - ))} + {artistItems.length > 0 && + (() => { + const noAlbums = albumItems.length === 0; + const numCols = Math.min(noAlbums ? 5 : 3, artistItems.length); + const numRows = Math.ceil(artistItems.length / numCols); + const gridHeight = noAlbums ? (numRows === 1 ? 200 : numRows * 80) : albumItems.length * 62 - 2; + return ( +
    +

    Artists

    +
    + {artistItems.map((item, i) => ( + openItem(item)} /> + ))} +
    -
    - ); - })()} + ); + })()}
    )} diff --git a/apps/desktop/renderer/src/components/LeaderboardPanel.tsx b/apps/desktop/renderer/src/components/LeaderboardPanel.tsx index 18beb84..3d2c489 100644 --- a/apps/desktop/renderer/src/components/LeaderboardPanel.tsx +++ b/apps/desktop/renderer/src/components/LeaderboardPanel.tsx @@ -211,7 +211,12 @@ export function LeaderboardPanel() { - +
    - +
    @@ -314,7 +327,9 @@ export function LeaderboardPanel() { }, }) } - onResolveArtist={(name) => resolveAndOpen(name, 'artist', { serviceId: t.serviceId, accountId: t.accountId })} + onResolveArtist={(name) => + resolveAndOpen(name, 'artist', { serviceId: t.serviceId, accountId: t.accountId }) + } /> ) : null} {t.artist && t.album ? ' · ' : null} @@ -446,7 +461,9 @@ export function LeaderboardPanel() { }, }) } - onResolveArtist={(name) => resolveAndOpen(name, 'artist', { serviceId: a.serviceId, accountId: a.accountId })} + onResolveArtist={(name) => + resolveAndOpen(name, 'artist', { serviceId: a.serviceId, accountId: a.accountId }) + } /> ) : null} @@ -503,9 +520,7 @@ export function LeaderboardPanel() { ); })}
    -
    - Fewer than 3 played days = Provisional (no tier) -
    +
    Fewer than 3 played days = Provisional (no tier)
    )} diff --git a/apps/desktop/renderer/src/components/LyricsPanel.module.css b/apps/desktop/renderer/src/components/LyricsPanel.module.css index d32b2de..2c0a198 100644 --- a/apps/desktop/renderer/src/components/LyricsPanel.module.css +++ b/apps/desktop/renderer/src/components/LyricsPanel.module.css @@ -13,8 +13,12 @@ } @keyframes panelIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } /* ── Background layers ── */ @@ -173,7 +177,9 @@ color: #fff; line-height: 1.4; cursor: default; - transition: opacity 0.55s ease, font-size 0.45s ease; + transition: + opacity 0.55s ease, + font-size 0.45s ease; } .lineActive { @@ -226,17 +232,36 @@ animation: dot 1.2s ease-in-out infinite; } -.dots span:nth-child(2) { animation-delay: 0.2s; } -.dots span:nth-child(3) { animation-delay: 0.4s; } +.dots span:nth-child(2) { + animation-delay: 0.2s; +} +.dots span:nth-child(3) { + animation-delay: 0.4s; +} @keyframes breathe { - 0%, 100% { opacity: 0.45; transform: scale(0.96); } - 50% { opacity: 1; transform: scale(1.04); } + 0%, + 100% { + opacity: 0.45; + transform: scale(0.96); + } + 50% { + opacity: 1; + transform: scale(1.04); + } } @keyframes dot { - 0%, 80%, 100% { opacity: 0.25; transform: translateY(0); } - 40% { opacity: 1; transform: translateY(-4px); } + 0%, + 80%, + 100% { + opacity: 0.25; + transform: translateY(0); + } + 40% { + opacity: 1; + transform: translateY(-4px); + } } @media (max-width: 800px) { diff --git a/apps/desktop/renderer/src/components/LyricsPanel.tsx b/apps/desktop/renderer/src/components/LyricsPanel.tsx index 4b8c680..8b4bb28 100644 --- a/apps/desktop/renderer/src/components/LyricsPanel.tsx +++ b/apps/desktop/renderer/src/components/LyricsPanel.tsx @@ -16,13 +16,35 @@ function renderDom(node: GeniusDomNode, key: number | string): React.ReactNode { if (typeof node === 'string') return node; const children = node.children?.map((c, i) => renderDom(c, i)) ?? null; switch (node.tag) { - case 'root': return <>{children}; - case 'p': return

    {children}

    ; - case 'em': return {children}; - case 'strong': return {children}; - case 'a': return { const href = node.attributes?.href; if (href) window.sonos.openExternal(href); }}>{children}; - case 'br': return
    ; - default: return {children}; + case 'root': + return <>{children}; + case 'p': + return ( +

    + {children} +

    + ); + case 'em': + return {children}; + case 'strong': + return {children}; + case 'a': + return ( + { + const href = node.attributes?.href; + if (href) window.sonos.openExternal(href); + }} + > + {children} + + ); + case 'br': + return
    ; + default: + return {children}; } } @@ -41,8 +63,12 @@ export function LyricsPanel({ playback }: Props) { const elapsedMs = Math.round((progressPct / 100) * durationMs) + 250; - const { lines, isLoading: lyricsLoading, isInstrumental, notFound } = - useLyrics(displayTrack, displayArtist, albumName, durationMs); + const { + lines, + isLoading: lyricsLoading, + isInstrumental, + notFound, + } = useLyrics(displayTrack, displayArtist, albumName, durationMs); const { description } = useGeniusDescription(displayTrack, displayArtist); @@ -65,7 +91,9 @@ export function LyricsPanel({ playback }: Props) { }, [activeLine]); useEffect(() => { - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [onClose]); @@ -85,27 +113,29 @@ export function LyricsPanel({ playback }: Props) { {cachedArt ? ( ) : ( -
    +
    + +
    )}
    {displayTrack || '—'}
    {displayArtist || '—'}
    - {description && ( -
    - {renderDom(description, 0)} -
    - )} + {description &&
    {renderDom(description, 0)}
    }
    {/* Right — scrolling lyrics */}
    {lyricsLoading && (
    -
    +
    + +
    Loading
    - + + +
    )} @@ -117,7 +147,9 @@ export function LyricsPanel({ playback }: Props) { return (
    { lineRefs.current[i] = el; }} + ref={(el) => { + lineRefs.current[i] = el; + }} className={`${styles.line}${i === activeLine ? ' ' + styles.lineActive : ''}`} style={activeLine >= 0 ? { opacity: Math.max(0.18, 1 - dist * 0.19) } : undefined} > diff --git a/apps/desktop/renderer/src/components/MiniPlayer.tsx b/apps/desktop/renderer/src/components/MiniPlayer.tsx index 8207301..616e5e4 100644 --- a/apps/desktop/renderer/src/components/MiniPlayer.tsx +++ b/apps/desktop/renderer/src/components/MiniPlayer.tsx @@ -19,10 +19,13 @@ export function MiniPlayerShell() { // same group the user has selected in the main window, not just groups[0]. useEffect(() => { if (groups.length === 0 || activeGroupId) return; - getActiveProvider().getActiveGroup().then((savedCoordinatorId) => { - const match = savedCoordinatorId ? groups.find((g) => g.coordinatorId === savedCoordinatorId) : null; - setActiveGroupId(match ? match.id : groups[0].id); - }).catch(() => setActiveGroupId(groups[0].id)); + getActiveProvider() + .getActiveGroup() + .then((savedCoordinatorId) => { + const match = savedCoordinatorId ? groups.find((g) => g.coordinatorId === savedCoordinatorId) : null; + setActiveGroupId(match ? match.id : groups[0].id); + }) + .catch(() => setActiveGroupId(groups[0].id)); }, [groups, activeGroupId]); const { playback } = usePlayback(activeGroupId); @@ -39,7 +42,17 @@ function MiniPlayer({ playback: ReturnType['playback']; isAuthed: boolean; }) { - const { displayTrack, displayArtist, cachedArt, progressPct, durationMs, isPlaying, dominantColor, elapsedLabel, durationLabel } = useNowPlaying(playback); + const { + displayTrack, + displayArtist, + cachedArt, + progressPct, + durationMs, + isPlaying, + dominantColor, + elapsedLabel, + durationLabel, + } = useNowPlaying(playback); const refresh = () => getActiveProvider().refreshPlayback(); @@ -47,7 +60,9 @@ function MiniPlayer({ if (!durationMs) return; const rect = e.currentTarget.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - getActiveProvider().seek(Math.floor(pct * durationMs)).then(refresh); + getActiveProvider() + .seek(Math.floor(pct * durationMs)) + .then(refresh); }; const shellStyle: React.CSSProperties | undefined = dominantColor @@ -69,12 +84,7 @@ function MiniPlayer({
    {displayTrack || '—'}
    {displayArtist || ''}
    -
    +
    @@ -104,7 +114,9 @@ function MiniPlayer({ - ) : } + ) : ( + + )} @@ -212,11 +202,27 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) const lyricsActive = pathname === '/lyrics'; const openItem = useOpenItem(); const { - displayTrack, displayArtist, cachedArt, dominantColor, - elapsedLabel, durationLabel, progressPct, durationMs, - isPlaying, isVisible, shuffle, repeat, volume, isExplicit, - albumItem, artistItem, prefetchAlbum, - currentObjectId, currentServiceId, currentAccountId, artUrlRaw, + displayTrack, + displayArtist, + cachedArt, + dominantColor, + elapsedLabel, + durationLabel, + progressPct, + durationMs, + isPlaying, + isVisible, + shuffle, + repeat, + volume, + isExplicit, + albumItem, + artistItem, + prefetchAlbum, + currentObjectId, + currentServiceId, + currentAccountId, + artUrlRaw, } = useNowPlaying(playback); const { isFavourited, toggle: toggleFavourite } = useFavourite(displayName, { @@ -234,21 +240,23 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) if (!durationMs) return; const rect = e.currentTarget.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - getActiveProvider().seek(Math.floor(pct * durationMs)).then(refresh); + getActiveProvider() + .seek(Math.floor(pct * durationMs)) + .then(refresh); }; const refresh = () => getActiveProvider().refreshPlayback(); - const toggleShuffle = () => - getActiveProvider().setPlayModes({ shuffle: !rawShuffle }).then(refresh).then(onShuffle); + const toggleShuffle = () => getActiveProvider().setPlayModes({ shuffle: !rawShuffle }).then(refresh).then(onShuffle); const toggleRepeat = () => { - const next = rawRepeat === "none" ? "all" : rawRepeat === "all" ? "one" : "none"; - getActiveProvider().setPlayModes({ repeat: next as 'none' | 'one' | 'all' }).then(refresh); + const next = rawRepeat === 'none' ? 'all' : rawRepeat === 'all' ? 'one' : 'none'; + getActiveProvider() + .setPlayModes({ repeat: next as 'none' | 'one' | 'all' }) + .then(refresh); }; - if (!isVisible) - return
    ; + if (!isVisible) return
    ; return (
    albumItem && openItem(albumItem)} /> @@ -283,19 +291,12 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props)
    - + {displayArtist && ( <> {' – '} {artistItem ? ( - ) : ( @@ -307,16 +308,8 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props)
    {elapsedLabel} -
    -
    +
    +
    {durationLabel}
    @@ -326,7 +319,7 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) {/* Centre — transport */}
    @@ -392,17 +379,13 @@ export function PlayerBar({ isAuthed, playback, onShuffle, displayName }: Props) )} -
    diff --git a/apps/desktop/renderer/src/components/PlaylistPanel.tsx b/apps/desktop/renderer/src/components/PlaylistPanel.tsx index e184630..28e972e 100644 --- a/apps/desktop/renderer/src/components/PlaylistPanel.tsx +++ b/apps/desktop/renderer/src/components/PlaylistPanel.tsx @@ -72,10 +72,7 @@ function TrackRow({ {index + 1}
    - {art - ? - :
    - } + {art ? :
    }
    {trackName}
    @@ -85,16 +82,37 @@ function TrackRow({ {track.addedBy && ( )}
    - + {isOwner && ( - + )}
    @@ -132,27 +150,30 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { if (index !== dragSrcIndex.current) setDragOverIndex(index); }, []); - const handleDrop = useCallback(async (index: number, e: React.DragEvent) => { - e.preventDefault(); - const raw = e.dataTransfer.getData('application/playlist-track-index'); - setDragOverIndex(null); - if (!raw || !playlist || !id) return; - const from = parseInt(raw, 10); - if (from === index) return; - const snapshot = playlist; - const newTracks = [...playlist.tracks]; - const [moved] = newTracks.splice(from, 1); - newTracks.splice(index, 0, moved); - queryClient.setQueryData(['playlist', id], { ...playlist, tracks: newTracks }); - try { - await window.sonos.reorderPlaylistTracks(id, from, index); - queryClient.invalidateQueries({ queryKey: ['playlist', id] }); - queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); - } catch { - queryClient.setQueryData(['playlist', id], snapshot); - showToast('Failed to reorder tracks'); - } - }, [playlist, id, queryClient, displayName]); + const handleDrop = useCallback( + async (index: number, e: React.DragEvent) => { + e.preventDefault(); + const raw = e.dataTransfer.getData('application/playlist-track-index'); + setDragOverIndex(null); + if (!raw || !playlist || !id) return; + const from = parseInt(raw, 10); + if (from === index) return; + const snapshot = playlist; + const newTracks = [...playlist.tracks]; + const [moved] = newTracks.splice(from, 1); + newTracks.splice(index, 0, moved); + queryClient.setQueryData(['playlist', id], { ...playlist, tracks: newTracks }); + try { + await window.sonos.reorderPlaylistTracks(id, from, index); + queryClient.invalidateQueries({ queryKey: ['playlist', id] }); + queryClient.invalidateQueries({ queryKey: ['playlists', 'owned', displayName] }); + } catch { + queryClient.setQueryData(['playlist', id], snapshot); + showToast('Failed to reorder tracks'); + } + }, + [playlist, id, queryClient, displayName] + ); const handleDragEnd = useCallback(() => { setDragOverIndex(null); @@ -186,7 +207,10 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { async function handleImageUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file || !id) return; - if (file.size > 5 * 1024 * 1024) { alert('Image must be under 5 MB'); return; } + if (file.size > 5 * 1024 * 1024) { + alert('Image must be under 5 MB'); + return; + } setUploading(true); try { const buffer = await file.arrayBuffer(); @@ -226,7 +250,10 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { async function commitRename() { const trimmed = renameValue.trim(); - if (!trimmed || !id || !playlist || trimmed === playlist.name) { setRenaming(false); return; } + if (!trimmed || !id || !playlist || trimmed === playlist.name) { + setRenaming(false); + return; + } setRenaming(false); const snapshot = playlist; queryClient.setQueryData(['playlist', id], { ...playlist, name: trimmed }); @@ -260,14 +287,19 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) {
    isOwner && fileInputRef.current?.click()} title={isOwner ? 'Change image' : undefined} > - {coverArtSrc - ? - : (!isLoading && playlist ? playlist.name[0].toUpperCase() : '') - } + {coverArtSrc ? ( + + ) : !isLoading && playlist ? ( + playlist.name[0].toUpperCase() + ) : ( + '' + )} {isOwner && (
    {uploading ? '…' : '📷'} @@ -292,9 +324,21 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { ref={renameInputRef} className={styles.renameInput} value={renameValue} - onChange={e => setRenameValue(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') { cancelRename.current = true; setRenaming(false); } }} - onBlur={() => { if (cancelRename.current) { cancelRename.current = false; return; } commitRename(); }} + onChange={(e) => setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') { + cancelRename.current = true; + setRenaming(false); + } + }} + onBlur={() => { + if (cancelRename.current) { + cancelRename.current = false; + return; + } + commitRename(); + }} autoFocus /> ) : ( @@ -318,11 +362,15 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { {playlist.isPublic && ( <> · - {playlist.members.length} member{playlist.members.length !== 1 ? 's' : ''} + + {playlist.members.length} member{playlist.members.length !== 1 ? 's' : ''} + )} · - {playlist.tracks.length} track{playlist.tracks.length !== 1 ? 's' : ''} + + {playlist.tracks.length} track{playlist.tracks.length !== 1 ? 's' : ''} + {playlist.createdAt && ( <> · @@ -332,26 +380,31 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) {
    {playlist.tracks.length > 0 && ( - + )} {playlist.isPublic && !isOwner && ( - )} - {isOwner && !playlist.isFavourites && ( - confirmDelete ? ( + {isOwner && + !playlist.isFavourites && + (confirmDelete ? ( <> - - + + ) : ( - - ) - )} + + ))}
    ) : ( @@ -366,7 +419,11 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { {isLoading ? ( <>
    - #TitleAdded by + # + + Title + Added by +
    {Array.from({ length: 6 }).map((_, i) => (
    @@ -400,10 +457,10 @@ export function PlaylistPanel({ displayName, onAddToQueue }: Props) { canReorder={canReorder} onAdd={() => onAddToQueue(trackToSonosItem(t), -1)} onRemove={() => handleRemoveTrack(t.uri, i)} - onContextMenu={e => showTrackMenu({ track: t, sonosItem: trackToSonosItem(t) }, e)} - onDragStart={e => handleDragStart(i, e)} - onDragOver={e => handleDragOver(i, e)} - onDrop={e => handleDrop(i, e)} + onContextMenu={(e) => showTrackMenu({ track: t, sonosItem: trackToSonosItem(t) }, e)} + onDragStart={(e) => handleDragStart(i, e)} + onDragOver={(e) => handleDragOver(i, e)} + onDrop={(e) => handleDrop(i, e)} onDragEnd={handleDragEnd} />
    diff --git a/apps/desktop/renderer/src/components/ProfilePanel.tsx b/apps/desktop/renderer/src/components/ProfilePanel.tsx index efd7d8f..ef40368 100644 --- a/apps/desktop/renderer/src/components/ProfilePanel.tsx +++ b/apps/desktop/renderer/src/components/ProfilePanel.tsx @@ -16,21 +16,65 @@ import { PlaylistCard } from './common/PlaylistCard'; import type { SonosItem } from '../types/sonos'; import styles from '../styles/ProfilePanel.module.css'; -function TrackSubtitle({ t, openItem, linkClass }: { t: StatsTrack; openItem: (item: SonosItem) => void; linkClass: string }) { +function TrackSubtitle({ + t, + openItem, + linkClass, +}: { + t: StatsTrack; + openItem: (item: SonosItem) => void; + linkClass: string; +}) { const artistItem: SonosItem | null = t.artistId - ? { type: 'ARTIST', name: t.artist, resource: { type: 'ARTIST', id: { objectId: t.artistId, serviceId: t.serviceId, accountId: t.accountId } } } - : null; - const albumItem: SonosItem | null = t.albumId && t.album - ? { type: 'ALBUM', name: t.album, resource: { type: 'ALBUM', id: { objectId: t.albumId, serviceId: t.serviceId, accountId: t.accountId } } } + ? { + type: 'ARTIST', + name: t.artist, + resource: { type: 'ARTIST', id: { objectId: t.artistId, serviceId: t.serviceId, accountId: t.accountId } }, + } : null; + const albumItem: SonosItem | null = + t.albumId && t.album + ? { + type: 'ALBUM', + name: t.album, + resource: { type: 'ALBUM', id: { objectId: t.albumId, serviceId: t.serviceId, accountId: t.accountId } }, + } + : null; return ( <> - {artistItem - ? - : t.artist} - {t.album && <>{' · '}{albumItem - ? - : t.album}} + {artistItem ? ( + + ) : ( + t.artist + )} + {t.album && ( + <> + {' · '} + {albumItem ? ( + + ) : ( + t.album + )} + + )} ); } @@ -75,11 +119,16 @@ export function ProfilePanel({ onAddToQueue, displayName, onSignOut, onChangeNam const [renameError, setRenameError] = useState(null); const [renameSaving, setRenameSaving] = useState(false); -const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, isLoading: statsLoading } = - useUserStats(userName, 25); + const { + topTracks, + artistItems: topArtists, + albumItems: topAlbums, + totalEvents, + isLoading: statsLoading, + } = useUserStats(userName, 25); const { data: rankings } = useGameRankings(userName, !!userName); - const ranking = rankings?.find(r => r.userName === userName) ?? null; + const ranking = rankings?.find((r) => r.userName === userName) ?? null; const isOwnProfile = !!displayName && displayName === userName; const { data: profile } = useUserProfile(userName); @@ -90,29 +139,52 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, const [trackSelected, setTrackSelected] = useState>(new Set()); const lastTrackSelected = useRef(null); - const handleTrackClick = useCallback((idx: number, e: React.MouseEvent) => { - e.stopPropagation(); - if (e.shiftKey && lastTrackSelected.current !== null) { - const lo = Math.min(lastTrackSelected.current, idx); - const hi = Math.max(lastTrackSelected.current, idx); - setTrackSelected(prev => { const next = new Set(prev); for (let j = lo; j <= hi; j++) next.add(j); return next; }); - } else if (e.ctrlKey || e.metaKey) { - setTrackSelected(prev => { const next = new Set(prev); if (next.has(idx)) next.delete(idx); else next.add(idx); return next; }); - lastTrackSelected.current = idx; - } else { - if (trackSelected.size === 1 && trackSelected.has(idx)) { setTrackSelected(new Set()); lastTrackSelected.current = null; } - else { setTrackSelected(new Set([idx])); lastTrackSelected.current = idx; } - } - }, [trackSelected]); + const handleTrackClick = useCallback( + (idx: number, e: React.MouseEvent) => { + e.stopPropagation(); + if (e.shiftKey && lastTrackSelected.current !== null) { + const lo = Math.min(lastTrackSelected.current, idx); + const hi = Math.max(lastTrackSelected.current, idx); + setTrackSelected((prev) => { + const next = new Set(prev); + for (let j = lo; j <= hi; j++) next.add(j); + return next; + }); + } else if (e.ctrlKey || e.metaKey) { + setTrackSelected((prev) => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + }); + lastTrackSelected.current = idx; + } else { + if (trackSelected.size === 1 && trackSelected.has(idx)) { + setTrackSelected(new Set()); + lastTrackSelected.current = null; + } else { + setTrackSelected(new Set([idx])); + lastTrackSelected.current = idx; + } + } + }, + [trackSelected] + ); - const handleTrackDragStart = useCallback((idx: number, e: React.DragEvent) => { - const indices = trackSelected.has(idx) ? [...trackSelected].sort((a, b) => a - b) : [idx]; - if (!trackSelected.has(idx)) { setTrackSelected(new Set([idx])); lastTrackSelected.current = idx; } - const items = indices.map(i => statsTrackToSonosItem(topTracks[i])); - e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(items)); - createDragGhost(items.length > 1 ? `${items.length} tracks` : topTracks[idx].trackName, e.dataTransfer); - }, [trackSelected, topTracks]); + const handleTrackDragStart = useCallback( + (idx: number, e: React.DragEvent) => { + const indices = trackSelected.has(idx) ? [...trackSelected].sort((a, b) => a - b) : [idx]; + if (!trackSelected.has(idx)) { + setTrackSelected(new Set([idx])); + lastTrackSelected.current = idx; + } + const items = indices.map((i) => statsTrackToSonosItem(topTracks[i])); + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(items)); + createDragGhost(items.length > 1 ? `${items.length} tracks` : topTracks[idx].trackName, e.dataTransfer); + }, + [trackSelected, topTracks] + ); async function handleAvatarUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; @@ -147,7 +219,7 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, const { owned: ownedPlaylists, joined: joinedPlaylists } = useMyPlaylists(userName); const allPlaylists = isOwnProfile ? [...ownedPlaylists, ...joinedPlaylists] - : ownedPlaylists.filter(p => p.isPublic); + : ownedPlaylists.filter((p) => p.isPublic); const visiblePlaylists = [...allPlaylists].sort((a, b) => { if (!!b.isFavourites !== !!a.isFavourites) return b.isFavourites ? 1 : -1; return b.updatedAt - a.updatedAt; @@ -167,10 +239,7 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, onClick={isOwnProfile ? () => fileInputRef.current?.click() : undefined} title={isOwnProfile ? 'Change photo' : undefined} > - {profileArt - ? - : userName[0].toUpperCase() - } + {profileArt ? : userName[0].toUpperCase()} {isOwnProfile && (
    {uploading ? '…' : '📷'} @@ -191,7 +260,10 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, { setRenameValue(e.target.value); setRenameError(null); }} + onChange={(e) => { + setRenameValue(e.target.value); + setRenameError(null); + }} autoFocus maxLength={32} spellCheck={false} @@ -199,7 +271,14 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, - {renameError && {renameError}} @@ -211,18 +290,30 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, {(totalEvents > 0 || isOwnProfile) && ( {totalEvents > 0 && ( - <>{totalEvents.toLocaleString()} plays + <> + {totalEvents.toLocaleString()} plays + )} {isOwnProfile && onChangeName && !renaming && ( <> {totalEvents > 0 && |} - + )} {isOwnProfile && onSignOut && ( <> | - + )} @@ -232,14 +323,19 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents,
    {tierIcon && }
    - - {ranking.isProvisional ? 'Provisional' : ranking.tierName} - + {ranking.isProvisional ? 'Provisional' : ranking.tierName} - {ranking.isProvisional - ? <>{ranking.gamesPlayed} {ranking.gamesPlayed === 1 ? 'game' : 'games'} played - : <>{ranking.averagePercent.toFixed(0)}% avg · best {ranking.bestTotal} · {ranking.gamesPlayed} {ranking.gamesPlayed === 1 ? 'game' : 'games'} - } + {ranking.isProvisional ? ( + <> + {ranking.gamesPlayed} {ranking.gamesPlayed === 1 ? 'game' : 'games'} played + + ) : ( + <> + {ranking.averagePercent.toFixed(0)}% avg ·{' '} + best {ranking.bestTotal} · {ranking.gamesPlayed}{' '} + {ranking.gamesPlayed === 1 ? 'game' : 'games'} + + )}
    @@ -252,47 +348,60 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents,

    Top Tracks

    { setTrackSelected(new Set()); lastTrackSelected.current = null; }} + onClick={() => { + setTrackSelected(new Set()); + lastTrackSelected.current = null; + }} >
    {statsLoading ? Array.from({ length: 5 }).map((_, i) => ( -
    - )) - : topTracks.slice(0, 5).map((t, i) => ( - } - artUrl={t.imageUrl} - trailing={{t.count}×} - isSelected={trackSelected.has(i)} - onClick={e => handleTrackClick(i, e)} - draggable={!!t.uri} - onDragStart={t.uri ? e => handleTrackDragStart(i, e) : undefined} + className={styles.skelBlock} + style={{ height: 52, marginBottom: 2, animationDelay: `${i * 60}ms` }} /> )) - } + : topTracks + .slice(0, 5) + .map((t, i) => ( + } + artUrl={t.imageUrl} + trailing={{t.count}×} + isSelected={trackSelected.has(i)} + onClick={(e) => handleTrackClick(i, e)} + draggable={!!t.uri} + onDragStart={t.uri ? (e) => handleTrackDragStart(i, e) : undefined} + /> + ))}
    {statsLoading ? Array.from({ length: 5 }).map((_, i) => ( -
    - )) - : topTracks.slice(5, 10).map((t, i) => ( - } - artUrl={t.imageUrl} - trailing={{t.count}×} - isSelected={trackSelected.has(i + 5)} - onClick={e => handleTrackClick(i + 5, e)} - draggable={!!t.uri} - onDragStart={t.uri ? e => handleTrackDragStart(i + 5, e) : undefined} +
    )) - } + : topTracks + .slice(5, 10) + .map((t, i) => ( + } + artUrl={t.imageUrl} + trailing={{t.count}×} + isSelected={trackSelected.has(i + 5)} + onClick={(e) => handleTrackClick(i + 5, e)} + draggable={!!t.uri} + onDragStart={t.uri ? (e) => handleTrackDragStart(i + 5, e) : undefined} + /> + ))}
    @@ -302,7 +411,13 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents, {(statsLoading || topArtists.length > 0) && (

    Top Artists

    - +
    )} @@ -320,7 +435,9 @@ const { topTracks, artistItems: topArtists, albumItems: topAlbums, totalEvents,

    Playlists

    {isOwnProfile && ( - + )}
    diff --git a/apps/desktop/renderer/src/components/Splash.tsx b/apps/desktop/renderer/src/components/Splash.tsx index 15c7db4..8961694 100644 --- a/apps/desktop/renderer/src/components/Splash.tsx +++ b/apps/desktop/renderer/src/components/Splash.tsx @@ -8,18 +8,23 @@ interface Props { const MIN_MS = 900; export function Splash({ ready }: Props) { - const [fading, setFading] = useState(false); - const [gone, setGone] = useState(false); + const [fading, setFading] = useState(false); + const [gone, setGone] = useState(false); const mountRef = useRef(0); - useLayoutEffect(() => { mountRef.current = Date.now(); }, []); + useLayoutEffect(() => { + mountRef.current = Date.now(); + }, []); useEffect(() => { if (!ready) return; - const elapsed = Date.now() - mountRef.current; + const elapsed = Date.now() - mountRef.current; const remaining = Math.max(0, MIN_MS - elapsed); - const t1 = setTimeout(() => setFading(true), remaining); - const t2 = setTimeout(() => setGone(true), remaining + 450); - return () => { clearTimeout(t1); clearTimeout(t2); }; + const t1 = setTimeout(() => setFading(true), remaining); + const t2 = setTimeout(() => setGone(true), remaining + 450); + return () => { + clearTimeout(t1); + clearTimeout(t2); + }; }, [ready]); if (gone) return null; @@ -30,7 +35,9 @@ export function Splash({ ready }: Props) {
    ♫
    True Tunes
    - + + +
    diff --git a/apps/desktop/renderer/src/components/TopNav.tsx b/apps/desktop/renderer/src/components/TopNav.tsx index 9510841..381d646 100644 --- a/apps/desktop/renderer/src/components/TopNav.tsx +++ b/apps/desktop/renderer/src/components/TopNav.tsx @@ -214,10 +214,21 @@ export function TopNav({ title="My profile" > - {profileArt - ? - : displayName[0].toUpperCase() - } + {profileArt ? ( + + ) : ( + displayName[0].toUpperCase() + )} {displayName} @@ -246,11 +257,7 @@ export function TopNav({ spellCheck={false} autoFocus /> -
    @@ -314,11 +321,9 @@ export function TopNav({ )} -
    - ); } diff --git a/apps/desktop/renderer/src/components/WindowControls.tsx b/apps/desktop/renderer/src/components/WindowControls.tsx index 55d0eb3..8e10d1c 100644 --- a/apps/desktop/renderer/src/components/WindowControls.tsx +++ b/apps/desktop/renderer/src/components/WindowControls.tsx @@ -7,7 +7,10 @@ export function WindowControls() { const [updateVersion, setUpdateVersion] = useState(null); useEffect(() => { - window.sonos.isWindowMaximized().then(setIsMaximized).catch(() => {}); + window.sonos + .isWindowMaximized() + .then(setIsMaximized) + .catch(() => {}); return window.sonos.onWindowMaximized(setIsMaximized); }, []); diff --git a/apps/desktop/renderer/src/components/__tests__/CardRow.test.tsx b/apps/desktop/renderer/src/components/__tests__/CardRow.test.tsx index a66bdfa..e2c3c1e 100644 --- a/apps/desktop/renderer/src/components/__tests__/CardRow.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/CardRow.test.tsx @@ -13,7 +13,7 @@ const makeItem = (name: string, type: string): SonosItem => ({ resource: { type, id: { objectId: name, serviceId: 'gm', accountId: 'acc' } }, }); -const onAdd = vi.fn(); +const onAdd = vi.fn(); const onOpen = vi.fn(); beforeEach(() => { @@ -23,9 +23,7 @@ beforeEach(() => { describe('CardRow', () => { describe('loading state', () => { it('renders exactly 6 placeholder divs when loading', () => { - const { container } = render( - - ); + const { container } = render(); const placeholders = container.querySelectorAll('.placeholder'); expect(placeholders).toHaveLength(6); }); @@ -41,9 +39,7 @@ describe('CardRow', () => { describe('empty state', () => { it('renders nothing when items is empty and not loading', () => { - const { container } = render( - - ); + const { container } = render(); expect(container.firstChild).toBeNull(); }); }); @@ -60,9 +56,7 @@ describe('CardRow', () => { describe('onAdd conditional', () => { it('shows add button for a TRACK item (non-container)', async () => { const user = userEvent.setup(); - render( - - ); + render(); const addBtn = screen.getByRole('button', { name: '+' }); await user.click(addBtn); expect(onAdd).toHaveBeenCalledOnce(); @@ -79,9 +73,7 @@ describe('CardRow', () => { describe('onOpen conditional', () => { it('makes an ALBUM card clickable (onOpen passed)', async () => { const user = userEvent.setup(); - render( - - ); + render(); // MediaCard renders an onClick on its root div when onOpen is provided const card = screen.getByText('Thriller').closest('.card') as HTMLElement; await user.click(card); @@ -90,9 +82,7 @@ describe('CardRow', () => { it('does not make a plain TRACK card openable', async () => { const user = userEvent.setup(); - render( - - ); + render(); const card = screen.getByText('Lose Yourself').closest('.card') as HTMLElement; await user.click(card); expect(onOpen).not.toHaveBeenCalled(); diff --git a/apps/desktop/renderer/src/components/__tests__/ChangelogDialog.test.tsx b/apps/desktop/renderer/src/components/__tests__/ChangelogDialog.test.tsx index b771832..302ec1d 100644 --- a/apps/desktop/renderer/src/components/__tests__/ChangelogDialog.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/ChangelogDialog.test.tsx @@ -8,8 +8,20 @@ const mockFetch = vi.fn(); (globalThis as any).fetch = mockFetch; const releases = [ - { tag_name: 'v1.2.0', name: 'Version 1.2.0', body: '## Changes\n- Feature A', published_at: '2024-03-01T00:00:00Z', html_url: 'https://github.com/releases/1' }, - { tag_name: 'v1.1.0', name: 'Version 1.1.0', body: null, published_at: '2024-02-01T00:00:00Z', html_url: 'https://github.com/releases/2' }, + { + tag_name: 'v1.2.0', + name: 'Version 1.2.0', + body: '## Changes\n- Feature A', + published_at: '2024-03-01T00:00:00Z', + html_url: 'https://github.com/releases/1', + }, + { + tag_name: 'v1.1.0', + name: 'Version 1.1.0', + body: null, + published_at: '2024-02-01T00:00:00Z', + html_url: 'https://github.com/releases/2', + }, ]; beforeEach(() => { diff --git a/apps/desktop/renderer/src/components/__tests__/DiscoverPanel.test.tsx b/apps/desktop/renderer/src/components/__tests__/DiscoverPanel.test.tsx index 69cbd47..f2f4e15 100644 --- a/apps/desktop/renderer/src/components/__tests__/DiscoverPanel.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/DiscoverPanel.test.tsx @@ -47,7 +47,7 @@ function renderPanel() { return render( - , + ); } @@ -55,7 +55,10 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(window.sonos.fetchDjSet).mockResolvedValue(djSet); vi.mocked(window.sonos.fetchOfficePresence).mockResolvedValue({ - inOffice: [], basis: 'none', observed: 0, detail: [], + inOffice: [], + basis: 'none', + observed: 0, + detail: [], }); vi.mocked(window.sonos.fetchUsers).mockResolvedValue([ { userId: 'Rich', lastQueued: 1, imageUrl: 'https://example.test/rich.png' }, @@ -85,11 +88,9 @@ describe('DiscoverPanel', () => { await userEvent.click(screen.getAllByTitle('Add to queue')[0]); expect(onAddToQueue).toHaveBeenCalledTimes(1); - expect(onAddToQueue).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Left Hand Free' }), - -1, - { attribute: false }, - ); + expect(onAddToQueue).toHaveBeenCalledWith(expect.objectContaining({ title: 'Left Hand Free' }), -1, { + attribute: false, + }); }); it('never attributes picks queued in bulk', async () => { @@ -143,9 +144,7 @@ describe('DiscoverPanel', () => { renderPanel(); // The tab is Discover — its default view should be the music the office // hasn't played, with favourites available as an aside. - expect( - await screen.findByText(/Nothing new found|Digging for something new/), - ).toBeInTheDocument(); + expect(await screen.findByText(/Nothing new found|Digging for something new/)).toBeInTheDocument(); }); it('splits the room from everyone else', async () => { @@ -170,13 +169,16 @@ describe('DiscoverPanel', () => { it('marks which of them is you', async () => { vi.mocked(window.sonos.fetchOfficePresence).mockResolvedValue({ - inOffice: ['Rich'], basis: 'sensed', observed: 1, detail: [], + inOffice: ['Rich'], + basis: 'sensed', + observed: 1, + detail: [], }); render( - , + ); expect(await screen.findByText('you')).toBeInTheDocument(); @@ -184,7 +186,10 @@ describe('DiscoverPanel', () => { it('says so plainly when someone has no Teams presence at all', async () => { vi.mocked(window.sonos.fetchOfficePresence).mockResolvedValue({ - inOffice: [], basis: 'none', observed: 0, detail: [], + inOffice: [], + basis: 'none', + observed: 0, + detail: [], }); renderPanel(); diff --git a/apps/desktop/renderer/src/components/__tests__/DisplayNameModal.test.tsx b/apps/desktop/renderer/src/components/__tests__/DisplayNameModal.test.tsx index b2fdd4e..fdaaa96 100644 --- a/apps/desktop/renderer/src/components/__tests__/DisplayNameModal.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/DisplayNameModal.test.tsx @@ -6,10 +6,10 @@ import { DisplayNameModal } from '../DisplayNameModal'; describe('DisplayNameModal', () => { it('renders the heading', () => { render(); - expect(screen.getByText("What should we call you?")).toBeInTheDocument(); + expect(screen.getByText('What should we call you?')).toBeInTheDocument(); }); - it("submit button is disabled when input is empty", () => { + it('submit button is disabled when input is empty', () => { render(); expect(screen.getByRole('button', { name: "Let's go" })).toBeDisabled(); }); diff --git a/apps/desktop/renderer/src/components/__tests__/ErrorBoundary.test.tsx b/apps/desktop/renderer/src/components/__tests__/ErrorBoundary.test.tsx index c29a7e8..dd52839 100644 --- a/apps/desktop/renderer/src/components/__tests__/ErrorBoundary.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/ErrorBoundary.test.tsx @@ -51,8 +51,11 @@ describe('ErrorBoundary', () => { ); - expect(window.sonos.trackEvent).toHaveBeenCalledWith('renderer_crash', expect.objectContaining({ - message: 'Test crash', - })); + expect(window.sonos.trackEvent).toHaveBeenCalledWith( + 'renderer_crash', + expect.objectContaining({ + message: 'Test crash', + }) + ); }); }); diff --git a/apps/desktop/renderer/src/components/__tests__/HomePanel.test.tsx b/apps/desktop/renderer/src/components/__tests__/HomePanel.test.tsx index 7268ac7..fb18cc2 100644 --- a/apps/desktop/renderer/src/components/__tests__/HomePanel.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/HomePanel.test.tsx @@ -46,7 +46,11 @@ vi.mock('../../hooks/useRecentlyPlayed', () => ({ function makeWrapper() { return ({ children }: { children: React.ReactNode }) => - createElement(QueryClientProvider, { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, children); + createElement( + QueryClientProvider, + { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, + children + ); } function makeRootData(rootItems: SonosItem[]) { @@ -101,17 +105,17 @@ describe('fetchYtmSections', () => { }); it('browses Home, New releases, Charts when items are present', async () => { - const homeItem = makeRootItem('Home', 'home-id'); - const nrItem = makeRootItem('New releases', 'nr-id'); - const chartsItem = makeRootItem('Charts', 'charts-id'); + const homeItem = makeRootItem('Home', 'home-id'); + const nrItem = makeRootItem('New releases', 'nr-id'); + const chartsItem = makeRootItem('Charts', 'charts-id'); mockBrowseContainer .mockResolvedValueOnce({ error: null, data: makeRootData([homeItem, nrItem, chartsItem]) }) .mockResolvedValue({ error: null, data: { items: [] } }); await fetchYtmSections(); - expect(mockBrowseContainer).toHaveBeenCalledWith('home-id', expect.anything()); - expect(mockBrowseContainer).toHaveBeenCalledWith('nr-id', expect.anything()); + expect(mockBrowseContainer).toHaveBeenCalledWith('home-id', expect.anything()); + expect(mockBrowseContainer).toHaveBeenCalledWith('nr-id', expect.anything()); expect(mockBrowseContainer).toHaveBeenCalledWith('charts-id', expect.anything()); }); @@ -129,7 +133,7 @@ describe('fetchYtmSections', () => { it('prepends supermix item to forYou when present', async () => { const supermixItem = makeRootItem('My Supermix', 'sm-id'); - const homeItem = makeRootItem('Home', 'home-id'); + const homeItem = makeRootItem('Home', 'home-id'); const track: SonosItem = { name: 'Home Track', type: 'TRACK' } as SonosItem; mockBrowseContainer .mockResolvedValueOnce({ error: null, data: makeRootData([supermixItem, homeItem]) }) diff --git a/apps/desktop/renderer/src/components/__tests__/MiniPlayer.test.tsx b/apps/desktop/renderer/src/components/__tests__/MiniPlayer.test.tsx index 3fb74c9..9a0a1b0 100644 --- a/apps/desktop/renderer/src/components/__tests__/MiniPlayer.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/MiniPlayer.test.tsx @@ -8,17 +8,31 @@ import { MiniPlayerShell } from '../MiniPlayer'; // Mock all the hooks that MiniPlayerShell pulls in const mockUseNowPlaying = vi.fn(); vi.mock('../../hooks/useNowPlaying', () => ({ useNowPlaying: (...a: unknown[]) => mockUseNowPlaying(...a) })); -vi.mock('../../hooks/useAuth', () => ({ useAuth: () => true })); -vi.mock('../../hooks/useGroups', () => ({ useGroups: () => [] })); +vi.mock('../../hooks/useAuth', () => ({ useAuth: () => true })); +vi.mock('../../hooks/useGroups', () => ({ useGroups: () => [] })); vi.mock('../../hooks/usePlayback', () => ({ usePlayback: () => ({ playback: { - isVisible: true, trackName: '', artistName: '', artUrl: null, - stateIcon: '▶', timeLabel: '', progressPct: 0, durationMs: 0, - isPlaying: false, shuffle: false, repeat: 'none', volume: 50, - currentObjectId: null, currentServiceId: null, currentAccountId: null, - currentAlbumId: null, currentAlbumName: null, isExplicit: false, - queueId: null, queueVersion: null, + isVisible: true, + trackName: '', + artistName: '', + artUrl: null, + stateIcon: '▶', + timeLabel: '', + progressPct: 0, + durationMs: 0, + isPlaying: false, + shuffle: false, + repeat: 'none', + volume: 50, + currentObjectId: null, + currentServiceId: null, + currentAccountId: null, + currentAlbumId: null, + currentAlbumName: null, + isExplicit: false, + queueId: null, + queueVersion: null, }, }), })); diff --git a/apps/desktop/renderer/src/components/__tests__/PlayerBar.test.tsx b/apps/desktop/renderer/src/components/__tests__/PlayerBar.test.tsx index 0614eab..85bf31c 100644 --- a/apps/desktop/renderer/src/components/__tests__/PlayerBar.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/PlayerBar.test.tsx @@ -117,17 +117,11 @@ beforeEach(() => { describe('PlayerBar — visibility', () => { it('renders nothing visible when isVisible is false', () => { - vi.mocked(useNowPlaying).mockReturnValue( - makeNowPlaying({ isVisible: false }) as ReturnType - ); + vi.mocked(useNowPlaying).mockReturnValue(makeNowPlaying({ isVisible: false }) as ReturnType); const qc = new QueryClient(); const { container } = render( - + ); expect(container.querySelector('[class*="bar"]')).toBeNull(); @@ -210,17 +204,13 @@ describe('PlayerBar — shuffle', () => { it('clicking Shuffle calls setPlayModes with toggled value', async () => { const { user } = setup({ shuffle: false }); await user.click(screen.getByTitle('Shuffle')); - await waitFor(() => - expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ shuffle: true }) - ); + await waitFor(() => expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ shuffle: true })); }); it('toggling shuffle off calls setPlayModes with false', async () => { const { user } = setup({ shuffle: true }); await user.click(screen.getByTitle('Shuffle')); - await waitFor(() => - expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ shuffle: false }) - ); + await waitFor(() => expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ shuffle: false })); }); it('shuffle button calls onShuffle prop', async () => { @@ -237,25 +227,19 @@ describe('PlayerBar — repeat', () => { it('clicking Repeat when off sets to all', async () => { const { user } = setup({ repeat: 'none' }); await user.click(screen.getByTitle('Repeat off')); - await waitFor(() => - expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ repeat: true, repeatOne: false }) - ); + await waitFor(() => expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ repeat: true, repeatOne: false })); }); it('clicking Repeat when all sets to one', async () => { const { user } = setup({ repeat: 'all' }, { repeat: 'all' }); await user.click(screen.getByTitle('Repeat all')); - await waitFor(() => - expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ repeat: false, repeatOne: true }) - ); + await waitFor(() => expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ repeat: false, repeatOne: true })); }); it('clicking Repeat when one sets to none', async () => { const { user } = setup({ repeat: 'one' }, { repeat: 'one' }); await user.click(screen.getByTitle('Repeat one')); - await waitFor(() => - expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ repeat: false, repeatOne: false }) - ); + await waitFor(() => expect(window.sonos.setPlayModes).toHaveBeenCalledWith({ repeat: false, repeatOne: false })); }); }); @@ -272,9 +256,7 @@ describe('PlayerBar — seek bar', () => { act(() => { bar.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 100 })); }); - await waitFor(() => - expect(window.sonos.seek).toHaveBeenCalledWith(100000) - ); + await waitFor(() => expect(window.sonos.seek).toHaveBeenCalledWith(100000)); expect(window.sonos.refreshPlayback).toHaveBeenCalled(); }); @@ -287,9 +269,7 @@ describe('PlayerBar — seek bar', () => { act(() => { bar.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 50 })); }); - await waitFor(() => - expect(window.sonos.seek).toHaveBeenCalledWith(0) - ); + await waitFor(() => expect(window.sonos.seek).toHaveBeenCalledWith(0)); }); it('does not seek when durationMs is 0', async () => { diff --git a/apps/desktop/renderer/src/components/__tests__/PlaylistPanel.test.tsx b/apps/desktop/renderer/src/components/__tests__/PlaylistPanel.test.tsx index ef2f0a5..b78ce32 100644 --- a/apps/desktop/renderer/src/components/__tests__/PlaylistPanel.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/PlaylistPanel.test.tsx @@ -70,8 +70,8 @@ function renderPanel(qc: QueryClient) { createElement(PlaylistPanel, { displayName: OWNER, onAddToQueue: vi.fn() as (item: SonosItem, position?: number) => void, - }), - ), + }) + ) ); } @@ -95,10 +95,7 @@ describe('handleRemoveTrack', () => { fireEvent.click(removeBtn); await waitFor(() => { - expect(vi.mocked(window.sonos.removeTrackFromPlaylist)).toHaveBeenCalledWith( - PLAYLIST_ID, - TRACK_URI, - ); + expect(vi.mocked(window.sonos.removeTrackFromPlaylist)).toHaveBeenCalledWith(PLAYLIST_ID, TRACK_URI); }); const updated = qc.getQueryData(['playlist', PLAYLIST_ID]); diff --git a/apps/desktop/renderer/src/components/__tests__/Splash.test.tsx b/apps/desktop/renderer/src/components/__tests__/Splash.test.tsx index 61e4ad7..dc92d3e 100644 --- a/apps/desktop/renderer/src/components/__tests__/Splash.test.tsx +++ b/apps/desktop/renderer/src/components/__tests__/Splash.test.tsx @@ -17,7 +17,9 @@ describe('Splash', () => { vi.useFakeTimers(); const { container } = render(); // After 900ms remaining + 450ms fade - act(() => { vi.advanceTimersByTime(1400); }); + act(() => { + vi.advanceTimersByTime(1400); + }); expect(container.firstChild).toBeNull(); vi.useRealTimers(); }); diff --git a/apps/desktop/renderer/src/components/album/AlbumPanel.tsx b/apps/desktop/renderer/src/components/album/AlbumPanel.tsx index 2903612..d8c1560 100644 --- a/apps/desktop/renderer/src/components/album/AlbumPanel.tsx +++ b/apps/desktop/renderer/src/components/album/AlbumPanel.tsx @@ -25,7 +25,7 @@ export function AlbumPanel({ onAddToQueue }: Props) { const queryClient = useQueryClient(); const { resolveAndOpen } = useResolveAndOpen(); - const isPlaylistOrProgram = item ? (isPlaylist(item) || isProgram(item)) : false; + const isPlaylistOrProgram = item ? isPlaylist(item) || isProgram(item) : false; const { albumId, serviceId, accountId, defaults } = item ? resolveAlbumParams(item) : { albumId: undefined, serviceId: undefined, accountId: undefined, defaults: undefined }; @@ -33,14 +33,21 @@ export function AlbumPanel({ onAddToQueue }: Props) { const lastSelected = useRef(null); const [adding, setAdding] = useState(false); - const albumResult = useAlbumBrowse(isPlaylistOrProgram ? undefined : albumId, serviceId, accountId, defaults); + const albumResult = useAlbumBrowse(isPlaylistOrProgram ? undefined : albumId, serviceId, accountId, defaults); const playlistResult = usePlaylistBrowse(isPlaylistOrProgram ? albumId : undefined, serviceId, accountId, defaults); const isLoading = isPlaylistOrProgram ? playlistResult.isLoading : albumResult.isLoading; - const error = isPlaylistOrProgram ? playlistResult.error : albumResult.error; + const error = isPlaylistOrProgram ? playlistResult.error : albumResult.error; const data = isPlaylistOrProgram ? playlistResult.data - ? { title: null, artist: null, artUrl: null, tracks: playlistResult.data, totalTracks: playlistResult.data.length, artistItem: null } + ? { + title: null, + artist: null, + artUrl: null, + tracks: playlistResult.data, + totalTracks: playlistResult.data.length, + artistItem: null, + } : null : (albumResult.data ?? null); @@ -51,15 +58,12 @@ export function AlbumPanel({ onAddToQueue }: Props) { queryClient.prefetchQuery(artistQueryOptions(rid.objectId, rid.serviceId, rid.accountId, undefined)); }, [queryClient, data?.artistItem]); - const title = data?.title ?? item?.title ?? item?.name ?? ''; + const title = data?.title ?? item?.title ?? item?.name ?? ''; const artist = data?.artist ?? ((item as Record)?.['subtitle'] as string) ?? ''; const artUrl = data?.artUrl ?? (item ? getItemArt(item) : null); - const cachedArt = useImage(artUrl); + const cachedArt = useImage(artUrl); const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); - const year = useGeniusAlbumYear( - isPlaylistOrProgram ? null : title, - isPlaylistOrProgram ? null : artist, - ); + const year = useGeniusAlbumYear(isPlaylistOrProgram ? null : title, isPlaylistOrProgram ? null : artist); useEffect(() => { setSelected(new Set()); @@ -68,30 +72,47 @@ export function AlbumPanel({ onAddToQueue }: Props) { if (!item) return null; - const trackServiceId = (data?.tracks[0]?.id as SonosItemId | undefined)?.serviceId ?? serviceId ?? ''; - const trackAccountId = (data?.tracks[0]?.id as SonosItemId | undefined)?.accountId ?? accountId ?? ''; + const trackServiceId = (data?.tracks[0]?.id as SonosItemId | undefined)?.serviceId ?? serviceId ?? ''; + const trackAccountId = (data?.tracks[0]?.id as SonosItemId | undefined)?.accountId ?? accountId ?? ''; function handleTrackClick(index: number, e: React.MouseEvent) { e.stopPropagation(); if (e.shiftKey && lastSelected.current !== null) { const lo = Math.min(lastSelected.current, index); const hi = Math.max(lastSelected.current, index); - setSelected(prev => { const next = new Set(prev); for (let i = lo; i <= hi; i++) next.add(i); return next; }); + setSelected((prev) => { + const next = new Set(prev); + for (let i = lo; i <= hi; i++) next.add(i); + return next; + }); } else if (e.ctrlKey || e.metaKey) { - setSelected(prev => { const next = new Set(prev); if (next.has(index)) next.delete(index); else next.add(index); return next; }); + setSelected((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); lastSelected.current = index; } else { - if (selected.size === 1 && selected.has(index)) { setSelected(new Set()); lastSelected.current = null; } - else { setSelected(new Set([index])); lastSelected.current = index; } + if (selected.size === 1 && selected.has(index)) { + setSelected(new Set()); + lastSelected.current = null; + } else { + setSelected(new Set([index])); + lastSelected.current = index; + } } } function handleTrackDragStart(index: number, e: React.DragEvent) { if (!data) return; const toMove = selected.has(index) ? [...selected].sort((a, b) => a - b) : [index]; - if (!selected.has(index)) { setSelected(new Set([index])); lastSelected.current = index; } + if (!selected.has(index)) { + setSelected(new Set([index])); + lastSelected.current = index; + } e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map(idx => data.tracks[idx].raw))); + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map((idx) => data.tracks[idx].raw))); createDragGhost(toMove.length > 1 ? `${toMove.length} tracks` : data.tracks[index].title, e.dataTransfer); } @@ -99,7 +120,10 @@ export function AlbumPanel({ onAddToQueue }: Props) { const totalMins = Math.round(totalSecs / 60); const headerStyle = dominantColor - ? { background: `linear-gradient(180deg, rgba(${dominantColor}, 0.55) 0%, rgba(${dominantColor}, 0.2) 60%, transparent 100%)`, transition: 'background 0.8s ease' } + ? { + background: `linear-gradient(180deg, rgba(${dominantColor}, 0.55) 0%, rgba(${dominantColor}, 0.2) 60%, transparent 100%)`, + transition: 'background 0.8s ease', + } : undefined; function openArtist() { @@ -145,7 +169,9 @@ export function AlbumPanel({ onAddToQueue }: Props) { )} {data && (
    - {[data.totalTracks + ' songs', totalMins > 0 ? totalMins + ' min' : null, year].filter(Boolean).join(' \u2022 ')} + {[data.totalTracks + ' songs', totalMins > 0 ? totalMins + ' min' : null, year] + .filter(Boolean) + .join(' \u2022 ')}
    )}
    @@ -155,8 +181,11 @@ export function AlbumPanel({ onAddToQueue }: Props) { onClick={async () => { if (!item || !data?.tracks?.length) return; setAdding(true); - try { await onAddToQueue(item); } - finally { setAdding(false); } + try { + await onAddToQueue(item); + } finally { + setAdding(false); + } }} > {adding ? 'Adding…' : '+ Add to Queue'} @@ -168,8 +197,15 @@ export function AlbumPanel({ onAddToQueue }: Props) {
    { setSelected(new Set()); lastSelected.current = null; }} + style={ + { + '--track-cols': isPlaylistOrProgram ? '24px 42px 1fr 260px 260px 50px 28px' : '24px 1fr 160px 50px 28px', + } as React.CSSProperties + } + onClick={() => { + setSelected(new Set()); + lastSelected.current = null; + }} > {data && (
    @@ -185,14 +221,18 @@ export function AlbumPanel({ onAddToQueue }: Props) { {isLoading && ( <> -
    +
    +
    +
    {Array.from({ length: 10 }).map((_, i) => (
    {isPlaylistOrProgram &&
    }
    - {isPlaylistOrProgram &&
    } + {isPlaylistOrProgram && ( +
    + )}
    @@ -209,8 +249,8 @@ export function AlbumPanel({ onAddToQueue }: Props) { isSelected={selected.has(i)} serviceId={trackServiceId} accountId={trackAccountId} - onClick={e => handleTrackClick(i, e)} - onDragStart={e => handleTrackDragStart(i, e)} + onClick={(e) => handleTrackClick(i, e)} + onDragStart={(e) => handleTrackDragStart(i, e)} onAdd={() => onAddToQueue(track.raw)} /> ))} diff --git a/apps/desktop/renderer/src/components/album/AlbumTrackRow.tsx b/apps/desktop/renderer/src/components/album/AlbumTrackRow.tsx index c28e687..e7a327a 100644 --- a/apps/desktop/renderer/src/components/album/AlbumTrackRow.tsx +++ b/apps/desktop/renderer/src/components/album/AlbumTrackRow.tsx @@ -24,8 +24,14 @@ interface Props { } export function AlbumTrackRow({ - track, isPlaylistOrProgram, isSelected, serviceId, accountId, - onClick, onDragStart, onAdd, + track, + isPlaylistOrProgram, + isSelected, + serviceId, + accountId, + onClick, + onDragStart, + onAdd, }: Props) { const navigate = useNavigate(); const { showTrackMenu } = useTrackContextMenu(); @@ -47,7 +53,7 @@ export function AlbumTrackRow({ className={[styles.trackRow, isSelected ? styles.selected : ''].filter(Boolean).join(' ')} draggable onClick={onClick} - onContextMenu={e => showTrackMenu({ track: trackPayload }, e)} + onContextMenu={(e) => showTrackMenu({ track: trackPayload }, e)} onDragStart={onDragStart} > {track.ordinal} @@ -76,8 +82,8 @@ export function AlbumTrackRow({ {ai > 0 && ', '}
    - {isPlaylistOrProgram && (() => { - const albumId = track.albumId; - const albumName = track.albumName; - return ( -
    - {albumId && albumName - ? ( + {isPlaylistOrProgram && + (() => { + const albumId = track.albumId; + const albumName = track.albumName; + return ( +
    + {albumId && albumName ? ( - ) - : (albumName ?? '')} -
    - ); - })()} + ) : ( + (albumName ?? '') + )} +
    + ); + })()} {fmtDuration(track.durationSeconds)} - +
    ); } diff --git a/apps/desktop/renderer/src/components/album/__tests__/AlbumPanel.test.tsx b/apps/desktop/renderer/src/components/album/__tests__/AlbumPanel.test.tsx index c76573e..dc756df 100644 --- a/apps/desktop/renderer/src/components/album/__tests__/AlbumPanel.test.tsx +++ b/apps/desktop/renderer/src/components/album/__tests__/AlbumPanel.test.tsx @@ -17,9 +17,13 @@ const mockUseImage = vi.fn(); vi.mock('../../../hooks/useImage', () => ({ useImage: (...a: unknown[]) => mockUseImage(...a) })); const mockUseDominantColor = vi.fn(); -vi.mock('../../../hooks/useDominantColor', () => ({ useDominantColor: (...a: unknown[]) => mockUseDominantColor(...a) })); +vi.mock('../../../hooks/useDominantColor', () => ({ + useDominantColor: (...a: unknown[]) => mockUseDominantColor(...a), +})); -vi.mock('../../../hooks/useArtistBrowse', () => ({ artistQueryOptions: vi.fn(() => ({ queryKey: [], queryFn: async () => null })) })); +vi.mock('../../../hooks/useArtistBrowse', () => ({ + artistQueryOptions: vi.fn(() => ({ queryKey: [], queryFn: async () => null })), +})); const mockUseAlbumBrowse = vi.fn(); vi.mock('../../../hooks/useAlbumBrowse', () => ({ @@ -42,13 +46,16 @@ vi.mock('../AlbumTrackRow', () => ({ onClick: (e: React.MouseEvent) => void; onAdd: () => void; }) => ( -
    +
    {track.title} - +
    ), })); @@ -100,7 +107,11 @@ function makeAlbumData(overrides = {}) { } function wrapper({ children }: { children: React.ReactNode }) { - return createElement(QueryClientProvider, { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, children); + return createElement( + QueryClientProvider, + { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, + children + ); } beforeEach(() => { @@ -150,7 +161,11 @@ describe('AlbumPanel', () => { }); it('shows no tracks message when empty', () => { - mockUseAlbumBrowse.mockReturnValue({ data: makeAlbumData({ tracks: [], totalTracks: 0 }), isLoading: false, error: null }); + mockUseAlbumBrowse.mockReturnValue({ + data: makeAlbumData({ tracks: [], totalTracks: 0 }), + isLoading: false, + error: null, + }); render(, { wrapper }); expect(screen.getByText('No tracks found.')).toBeInTheDocument(); }); @@ -163,7 +178,11 @@ describe('AlbumPanel', () => { it('shows only song count when total duration is 0', () => { const tracks = [makeTrack('Song A', 0, 0), makeTrack('Song B', 1, 0)]; - mockUseAlbumBrowse.mockReturnValue({ data: makeAlbumData({ tracks, totalTracks: 2 }), isLoading: false, error: null }); + mockUseAlbumBrowse.mockReturnValue({ + data: makeAlbumData({ tracks, totalTracks: 2 }), + isLoading: false, + error: null, + }); render(, { wrapper }); const metaLine = screen.getByText(/2 songs/); expect(metaLine.textContent).not.toContain('min'); @@ -237,7 +256,11 @@ describe('AlbumPanel', () => { it('shift+click range-selects from last to target', () => { const tracks = Array.from({ length: 4 }, (_, i) => makeTrack(`Song ${i}`, i)); - mockUseAlbumBrowse.mockReturnValue({ data: makeAlbumData({ tracks, totalTracks: 4 }), isLoading: false, error: null }); + mockUseAlbumBrowse.mockReturnValue({ + data: makeAlbumData({ tracks, totalTracks: 4 }), + isLoading: false, + error: null, + }); render(, { wrapper }); fireEvent.click(screen.getByTestId('trk-0')); fireEvent.click(screen.getByTestId('trk-2'), { shiftKey: true }); diff --git a/apps/desktop/renderer/src/components/album/__tests__/AlbumTrackRow.test.tsx b/apps/desktop/renderer/src/components/album/__tests__/AlbumTrackRow.test.tsx index 8f81803..4b7ed56 100644 --- a/apps/desktop/renderer/src/components/album/__tests__/AlbumTrackRow.test.tsx +++ b/apps/desktop/renderer/src/components/album/__tests__/AlbumTrackRow.test.tsx @@ -80,36 +80,26 @@ describe('AlbumTrackRow', () => { const playlistProps = { ...baseProps, isPlaylistOrProgram: true }; it('renders artist buttons with navigation', () => { - render(); + render( + + ); const btn = screen.getByRole('button', { name: 'John Lennon' }); fireEvent.click(btn); - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/^\/artist\//), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringMatching(/^\/artist\//), expect.anything()); }); it('renders album link with navigation when albumId and albumName are set', () => { - render(); + render(); const btn = screen.getByRole('button', { name: 'Abbey Road' }); fireEvent.click(btn); - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringMatching(/^\/album\//), - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith(expect.stringMatching(/^\/album\//), expect.anything()); }); it('renders albumName as plain text when no albumId', () => { - render(); + render(); expect(screen.getByText('Unknown Album')).toBeInTheDocument(); }); }); diff --git a/apps/desktop/renderer/src/components/artist/ArtistAlbumCard.tsx b/apps/desktop/renderer/src/components/artist/ArtistAlbumCard.tsx index c55bb68..73a4c89 100644 --- a/apps/desktop/renderer/src/components/artist/ArtistAlbumCard.tsx +++ b/apps/desktop/renderer/src/components/artist/ArtistAlbumCard.tsx @@ -20,7 +20,15 @@ export function ArtistAlbumCard({ album, onOpen, onAdd }: Props) {
    {art ? :
    ♪
    }
    - +
    diff --git a/apps/desktop/renderer/src/components/artist/ArtistHero.module.css b/apps/desktop/renderer/src/components/artist/ArtistHero.module.css index adf7fed..65f8c87 100644 --- a/apps/desktop/renderer/src/components/artist/ArtistHero.module.css +++ b/apps/desktop/renderer/src/components/artist/ArtistHero.module.css @@ -19,8 +19,12 @@ transition: background 0.15s; border-bottom: 1px solid var(--border); } -.heroHeader:hover { background: rgba(255, 255, 255, 0.05); } -.heroHeader:hover .heroName { text-decoration: underline; } +.heroHeader:hover { + background: rgba(255, 255, 255, 0.05); +} +.heroHeader:hover .heroName { + text-decoration: underline; +} .heroAvatar { width: 48px; @@ -31,8 +35,17 @@ flex-shrink: 0; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); } -.heroAvatarImg { width: 100%; height: 100%; object-fit: cover; display: block; } -.heroAvatarPh { width: 100%; height: 100%; background: var(--bg-1); } +.heroAvatarImg { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.heroAvatarPh { + width: 100%; + height: 100%; + background: var(--bg-1); +} .heroName { font-size: 16px; @@ -64,10 +77,18 @@ cursor: grab; min-width: 0; } -.heroTrackRow:active { cursor: grabbing; } -.heroTrackRow:hover { background: rgba(255,255,255,0.07); } -.heroTrackSelected { background: rgba(255,255,255,0.1) !important; } -.heroTrackSelected:hover { background: rgba(255,255,255,0.13) !important; } +.heroTrackRow:active { + cursor: grabbing; +} +.heroTrackRow:hover { + background: rgba(255, 255, 255, 0.07); +} +.heroTrackSelected { + background: rgba(255, 255, 255, 0.1) !important; +} +.heroTrackSelected:hover { + background: rgba(255, 255, 255, 0.13) !important; +} .heroTrackArt { width: 32px; @@ -77,10 +98,22 @@ overflow: hidden; background: var(--bg-1); } -.heroTrackArt img { width: 100%; height: 100%; object-fit: cover; display: block; } -.heroTrackArtPh { width: 100%; height: 100%; background: var(--bg-2); } +.heroTrackArt img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.heroTrackArtPh { + width: 100%; + height: 100%; + background: var(--bg-2); +} -.heroTrackInfo { flex: 1; min-width: 0; } +.heroTrackInfo { + flex: 1; + min-width: 0; +} .heroTrackName { font-size: 12px; font-weight: 500; @@ -93,7 +126,9 @@ gap: 4px; } -.heroTrackExplicit { margin-left: auto; } +.heroTrackExplicit { + margin-left: auto; +} .heroTrackDur { font-size: 11px; @@ -118,8 +153,14 @@ opacity: 0; transition: all 0.15s; } -.heroTrackRow:hover .heroTrackAdd { opacity: 1; } -.heroTrackAdd:hover { border-color: var(--border-2); color: var(--text-2); background: var(--bg-2); } +.heroTrackRow:hover .heroTrackAdd { + opacity: 1; +} +.heroTrackAdd:hover { + border-color: var(--border-2); + color: var(--text-2); + background: var(--bg-2); +} .heroTrackPh { height: 44px; @@ -129,6 +170,11 @@ } @keyframes pulse { - 0%, 100% { opacity: 0.35; } - 50% { opacity: 0.6; } + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 0.6; + } } diff --git a/apps/desktop/renderer/src/components/artist/ArtistHero.tsx b/apps/desktop/renderer/src/components/artist/ArtistHero.tsx index 4b6e8b0..e4f63fb 100644 --- a/apps/desktop/renderer/src/components/artist/ArtistHero.tsx +++ b/apps/desktop/renderer/src/components/artist/ArtistHero.tsx @@ -24,28 +24,32 @@ export function ArtistHero({ enabled: !!(artistId && serviceId && accountId), }); - const cachedArt = useImage(getItemArt(artist)); + const cachedArt = useImage(getItemArt(artist)); const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); - const name = (artist.title ?? artist.name ?? '') as string; + const name = (artist.title ?? artist.name ?? '') as string; - const [selected, setSelected] = useState>(new Set()); - const lastSelected = useRef(null); - const tracks = data?.topSongs?.slice(0, 6) ?? []; + const [selected, setSelected] = useState>(new Set()); + const lastSelected = useRef(null); + const tracks = data?.topSongs?.slice(0, 6) ?? []; function handleClick(index: number, e: React.MouseEvent) { e.stopPropagation(); if (e.shiftKey && lastSelected.current !== null) { const lo = Math.min(lastSelected.current, index); const hi = Math.max(lastSelected.current, index); - setSelected(prev => { + setSelected((prev) => { const next = new Set(prev); for (let i = lo; i <= hi; i++) next.add(i); return next; }); } else if (e.ctrlKey || e.metaKey) { - setSelected(prev => { + setSelected((prev) => { const next = new Set(prev); - if (next.has(index)) { next.delete(index); } else { next.add(index); } + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } return next; }); lastSelected.current = index; @@ -62,29 +66,44 @@ export function ArtistHero({ function handleDragStart(index: number, e: React.DragEvent) { const toMove = selected.has(index) ? [...selected].sort((a, b) => a - b) : [index]; - if (!selected.has(index)) { setSelected(new Set([index])); lastSelected.current = index; } + if (!selected.has(index)) { + setSelected(new Set([index])); + lastSelected.current = index; + } e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map(i => tracks[i].raw))); + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map((i) => tracks[i].raw))); createDragGhost(toMove.length > 1 ? `${toMove.length} tracks` : getName(tracks[index].raw), e.dataTransfer); } return (
    { setSelected(new Set()); lastSelected.current = null; }} + style={ + dominantColor + ? { background: `linear-gradient(135deg, rgba(${dominantColor},0.18) 0%, transparent 70%)` } + : undefined + } + onClick={() => { + setSelected(new Set()); + lastSelected.current = null; + }} >
    { e.stopPropagation(); onOpen(artist); }} + onClick={(e) => { + e.stopPropagation(); + onOpen(artist); + }} role="button" tabIndex={0} - onKeyDown={e => e.key === 'Enter' && onOpen(artist)} + onKeyDown={(e) => e.key === 'Enter' && onOpen(artist)} >
    - {cachedArt - ? - :
    } + {cachedArt ? ( + + ) : ( +
    + )}
    {name}
    @@ -102,9 +121,7 @@ export function ArtistHero({ onAdd={() => onAddToQueue(track.raw)} /> )) - : Array.from({ length: 6 }).map((_, i) => ( -
    - ))} + : Array.from({ length: 6 }).map((_, i) =>
    )}
    ); diff --git a/apps/desktop/renderer/src/components/artist/ArtistPanel.tsx b/apps/desktop/renderer/src/components/artist/ArtistPanel.tsx index e446a89..8376e74 100644 --- a/apps/desktop/renderer/src/components/artist/ArtistPanel.tsx +++ b/apps/desktop/renderer/src/components/artist/ArtistPanel.tsx @@ -19,11 +19,9 @@ function domToText(node: GeniusDomNode): string { function extractBio(description: GeniusDomNode | null): string { if (!description || typeof description === 'string') return ''; const root = description as { tag: string; children?: GeniusDomNode[] }; - const paras = (root.children ?? []).filter( - c => typeof c !== 'string' && (c as { tag: string }).tag === 'p', - ); + const paras = (root.children ?? []).filter((c) => typeof c !== 'string' && (c as { tag: string }).tag === 'p'); return paras - .map(p => domToText(p as GeniusDomNode).trim()) + .map((p) => domToText(p as GeniusDomNode).trim()) .filter(Boolean) .join('\n\n'); } @@ -39,25 +37,27 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } const item = (state as { item?: SonosItem } | null)?.item; const openItem = useOpenItem(); - const { artistId, serviceId, accountId, defaults, name: fallbackName } = - item - ? resolveArtistParams(item) - : { artistId: undefined, serviceId: undefined, accountId: undefined, defaults: undefined, name: undefined }; + const { + artistId, + serviceId, + accountId, + defaults, + name: fallbackName, + } = item + ? resolveArtistParams(item) + : { artistId: undefined, serviceId: undefined, accountId: undefined, defaults: undefined, name: undefined }; const { data, isLoading } = useArtistBrowse( artistId, serviceId, accountId, defaults, - fallbackName ?? (item?.title as string | undefined) ?? (item?.name as string | undefined), + fallbackName ?? (item?.title as string | undefined) ?? (item?.name as string | undefined) ); const name = data?.name ?? fallbackName ?? item?.title ?? item?.name ?? ''; const imageUrl = - data?.imageUrl ?? - (item?.images as Record | undefined)?.['tile1x1'] ?? - item?.imageUrl ?? - null; + data?.imageUrl ?? (item?.images as Record | undefined)?.['tile1x1'] ?? item?.imageUrl ?? null; const [showAllSongs, setShowAllSongs] = useState(false); const [selected, setSelected] = useState>(new Set()); @@ -70,13 +70,13 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } if (e.shiftKey && lastSelected.current !== null) { const lo = Math.min(lastSelected.current, index); const hi = Math.max(lastSelected.current, index); - setSelected(prev => { + setSelected((prev) => { const next = new Set(prev); for (let i = lo; i <= hi; i++) next.add(i); return next; }); } else if (e.ctrlKey || e.metaKey) { - setSelected(prev => { + setSelected((prev) => { const next = new Set(prev); if (next.has(index)) next.delete(index); else next.add(index); @@ -96,15 +96,26 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } function handleDragStart(index: number, e: React.DragEvent) { const toMove = selected.has(index) ? [...selected].sort((a, b) => a - b) : [index]; - if (!selected.has(index)) { setSelected(new Set([index])); lastSelected.current = index; } + if (!selected.has(index)) { + setSelected(new Set([index])); + lastSelected.current = index; + } e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map(i => visibleSongs[i].raw))); + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map((i) => visibleSongs[i].raw))); const ghost = document.createElement('div'); Object.assign(ghost.style, { - position: 'fixed', top: '-100px', left: '0', - background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', - color: '#fff', padding: '5px 12px', borderRadius: '6px', - fontSize: '12px', fontWeight: '600', pointerEvents: 'none', whiteSpace: 'nowrap', + position: 'fixed', + top: '-100px', + left: '0', + background: 'rgba(255,255,255,0.15)', + backdropFilter: 'blur(8px)', + color: '#fff', + padding: '5px 12px', + borderRadius: '6px', + fontSize: '12px', + fontWeight: '600', + pointerEvents: 'none', + whiteSpace: 'nowrap', }); ghost.textContent = toMove.length > 1 ? `${toMove.length} tracks` : visibleSongs[index].title; document.body.appendChild(ghost); @@ -112,14 +123,14 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } setTimeout(() => ghost.remove(), 0); } - const cachedArt = useImage(imageUrl); + const cachedArt = useImage(imageUrl); const dominantColor = useDominantColor(cachedArt, { setGlobal: true }); const latestAlbum = data?.albums[0] ?? null; - const genius = data?.genius ?? null; - const blurb = genius ? extractBio(genius.description) : ''; - const altNames = genius?.alternateNames?.filter(Boolean).slice(0, 4) ?? []; + const genius = data?.genius ?? null; + const blurb = genius ? extractBio(genius.description) : ''; + const altNames = genius?.alternateNames?.filter(Boolean).slice(0, 4) ?? []; if (!item) return null; return ( @@ -129,21 +140,24 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } className={styles.header} style={ dominantColor - ? { background: `linear-gradient(180deg, rgba(${dominantColor},0.55) 0%, rgba(${dominantColor},0.2) 60%, transparent 100%)`, transition: 'background 0.8s ease' } + ? { + background: `linear-gradient(180deg, rgba(${dominantColor},0.55) 0%, rgba(${dominantColor},0.2) 60%, transparent 100%)`, + transition: 'background 0.8s ease', + } : undefined } >
    - {cachedArt - ? - :
    } + {cachedArt ? ( + + ) : ( +
    + )}
    {name}
    - {altNames.length > 0 && ( -
    {altNames.join(' · ')}
    - )} + {altNames.length > 0 &&
    {altNames.join(' · ')}
    }
    @@ -152,7 +166,9 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } {isLoading ? ( <>
    - {Array.from({ length: 6 }).map((_, i) =>
    )} + {Array.from({ length: 6 }).map((_, i) => ( +
    + ))}
    @@ -166,7 +182,10 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive } <> @@ -192,7 +211,9 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive }
    About
    {blurb.split('\n\n').map((para, i) => ( -

    {para}

    +

    + {para} +

    ))}
    )} @@ -200,18 +221,35 @@ export function ArtistPanel({ onAddToQueue, currentTrackName, isPlaybackActive }
    {genius.instagram && ( - +
    ); } diff --git a/apps/desktop/renderer/src/components/artist/TopSongRow.tsx b/apps/desktop/renderer/src/components/artist/TopSongRow.tsx index ac838bc..f8f3e57 100644 --- a/apps/desktop/renderer/src/components/artist/TopSongRow.tsx +++ b/apps/desktop/renderer/src/components/artist/TopSongRow.tsx @@ -17,7 +17,16 @@ interface Props { onDragStart: (index: number, e: React.DragEvent) => void; } -export function TopSongRow({ track, index, isSelected, isCurrentTrack, isPlaybackActive, onAdd, onClick, onDragStart }: Props) { +export function TopSongRow({ + track, + index, + isSelected, + isCurrentTrack, + isPlaybackActive, + onAdd, + onClick, + onDragStart, +}: Props) { const art = useImage(track.artUrl); const { showTrackMenu } = useTrackContextMenu(); const subtitle = (track.raw as Record)?.['subtitle'] as string | undefined; @@ -38,9 +47,9 @@ export function TopSongRow({ track, index, isSelected, isCurrentTrack, isPlaybac
    onClick(index, e)} - onContextMenu={e => showTrackMenu({ track: trackPayload }, e)} - onDragStart={e => onDragStart(index, e)} + onClick={(e) => onClick(index, e)} + onContextMenu={(e) => showTrackMenu({ track: trackPayload }, e)} + onDragStart={(e) => onDragStart(index, e)} > {isCurrentTrack ? (
    @@ -62,7 +71,15 @@ export function TopSongRow({ track, index, isSelected, isCurrentTrack, isPlaybac {subtitle && {subtitle}}
    {fmtDuration(track.durationSeconds)} - +
    ); } diff --git a/apps/desktop/renderer/src/components/artist/__tests__/ArtistHero.test.tsx b/apps/desktop/renderer/src/components/artist/__tests__/ArtistHero.test.tsx index c55cf4f..18b9d1b 100644 --- a/apps/desktop/renderer/src/components/artist/__tests__/ArtistHero.test.tsx +++ b/apps/desktop/renderer/src/components/artist/__tests__/ArtistHero.test.tsx @@ -10,7 +10,9 @@ const mockUseImage = vi.fn(); vi.mock('../../../hooks/useImage', () => ({ useImage: (...a: unknown[]) => mockUseImage(...a) })); const mockUseDominantColor = vi.fn(); -vi.mock('../../../hooks/useDominantColor', () => ({ useDominantColor: (...a: unknown[]) => mockUseDominantColor(...a) })); +vi.mock('../../../hooks/useDominantColor', () => ({ + useDominantColor: (...a: unknown[]) => mockUseDominantColor(...a), +})); const QUERY_KEY = ['artist-hero-test']; vi.mock('../../../hooks/useArtistBrowse', () => ({ @@ -34,13 +36,16 @@ vi.mock('../HeroTrackRow', () => ({ onClick: (i: number, e: React.MouseEvent) => void; onAdd: () => void; }) => ( -
    onClick(index, e)} - > +
    onClick(index, e)}> {track.title} - +
    ), })); @@ -85,36 +90,32 @@ describe('ArtistHero', () => { // ── basic rendering ────────────────────────────────────────────────────────── it('renders artist name', () => { - render( - , - { wrapper: makeWrapper(makeClient()) } - ); + render(, { + wrapper: makeWrapper(makeClient()), + }); expect(screen.getByText('The Beatles')).toBeInTheDocument(); }); it('shows 6 placeholder rows when no data is loaded', () => { - const { container } = render( - , - { wrapper: makeWrapper(makeClient()) } - ); + const { container } = render(, { + wrapper: makeWrapper(makeClient()), + }); expect(container.querySelectorAll('[class*="heroTrackPh"]').length).toBe(6); }); it('shows art image when useImage returns a URL', () => { mockUseImage.mockReturnValue('http://img.com/art.jpg'); - const { container } = render( - , - { wrapper: makeWrapper(makeClient()) } - ); + const { container } = render(, { + wrapper: makeWrapper(makeClient()), + }); expect(container.querySelector('img[src="http://img.com/art.jpg"]')).toBeTruthy(); }); it('applies gradient style when dominant color is set', () => { mockUseDominantColor.mockReturnValue('200, 100, 50'); - const { container } = render( - , - { wrapper: makeWrapper(makeClient()) } - ); + const { container } = render(, { + wrapper: makeWrapper(makeClient()), + }); const hero = container.querySelector('[class*="artistHero"]') as HTMLElement; expect(hero?.style.background).toContain('200, 100, 50'); }); @@ -124,10 +125,9 @@ describe('ArtistHero', () => { it('calls onOpen when header is clicked', async () => { const onOpen = vi.fn(); const user = userEvent.setup(); - render( - , - { wrapper: makeWrapper(makeClient()) } - ); + render(, { + wrapper: makeWrapper(makeClient()), + }); await user.click(screen.getByRole('button')); expect(onOpen).toHaveBeenCalledWith(artistItem); }); @@ -135,10 +135,9 @@ describe('ArtistHero', () => { it('calls onOpen when Enter is pressed on hero header', async () => { const onOpen = vi.fn(); const user = userEvent.setup(); - render( - , - { wrapper: makeWrapper(makeClient()) } - ); + render(, { + wrapper: makeWrapper(makeClient()), + }); screen.getByRole('button').focus(); await user.keyboard('{Enter}'); expect(onOpen).toHaveBeenCalled(); @@ -148,20 +147,18 @@ describe('ArtistHero', () => { it('renders tracks when data has topSongs', () => { const songs = [makeSong('Come Together', 0), makeSong('Let It Be', 1)]; - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); expect(screen.getByText('Come Together')).toBeInTheDocument(); expect(screen.getByText('Let It Be')).toBeInTheDocument(); }); it('renders at most 6 tracks even when data has more', () => { const songs = Array.from({ length: 8 }, (_, i) => makeSong(`Song ${i + 1}`, i)); - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); expect(screen.queryByText('Song 7')).not.toBeInTheDocument(); expect(screen.getByText('Song 6')).toBeInTheDocument(); }); @@ -169,10 +166,9 @@ describe('ArtistHero', () => { it('calls onAddToQueue when Add button is clicked on a track', () => { const onAddToQueue = vi.fn(); const songs = [makeSong('Come Together', 0)]; - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByText('Add')); expect(onAddToQueue).toHaveBeenCalledWith(songs[0].raw); }); @@ -181,10 +177,9 @@ describe('ArtistHero', () => { it('single-clicking a track selects it', () => { const songs = [makeSong('Song A', 0), makeSong('Song B', 1)]; - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByTestId('track-0')); expect(screen.getByTestId('track-0')).toHaveAttribute('data-selected', 'true'); expect(screen.getByTestId('track-1')).toHaveAttribute('data-selected', 'false'); @@ -192,10 +187,9 @@ describe('ArtistHero', () => { it('clicking a selected track deselects it', () => { const songs = [makeSong('Song A', 0)]; - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByTestId('track-0')); expect(screen.getByTestId('track-0')).toHaveAttribute('data-selected', 'true'); fireEvent.click(screen.getByTestId('track-0')); @@ -204,10 +198,9 @@ describe('ArtistHero', () => { it('ctrl+click toggles an additional track into selection', () => { const songs = [makeSong('Song A', 0), makeSong('Song B', 1)]; - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByTestId('track-0')); fireEvent.click(screen.getByTestId('track-1'), { ctrlKey: true }); expect(screen.getByTestId('track-0')).toHaveAttribute('data-selected', 'true'); @@ -216,10 +209,9 @@ describe('ArtistHero', () => { it('ctrl+click on a selected track deselects it', () => { const songs = [makeSong('Song A', 0), makeSong('Song B', 1)]; - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByTestId('track-0')); fireEvent.click(screen.getByTestId('track-1'), { ctrlKey: true }); fireEvent.click(screen.getByTestId('track-0'), { ctrlKey: true }); @@ -229,10 +221,9 @@ describe('ArtistHero', () => { it('shift+click range-selects from last clicked to target', () => { const songs = Array.from({ length: 5 }, (_, i) => makeSong(`Song ${i}`, i)); - render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByTestId('track-1')); fireEvent.click(screen.getByTestId('track-3'), { shiftKey: true }); expect(screen.getByTestId('track-1')).toHaveAttribute('data-selected', 'true'); @@ -244,10 +235,9 @@ describe('ArtistHero', () => { it('clicking the outer div clears selection', () => { const songs = [makeSong('Song A', 0)]; - const { container } = render( - , - { wrapper: makeWrapper(makeClient(songs)) } - ); + const { container } = render(, { + wrapper: makeWrapper(makeClient(songs)), + }); fireEvent.click(screen.getByTestId('track-0')); expect(screen.getByTestId('track-0')).toHaveAttribute('data-selected', 'true'); // Click the outer hero div (not the track or header) diff --git a/apps/desktop/renderer/src/components/artist/__tests__/smallComponents.test.tsx b/apps/desktop/renderer/src/components/artist/__tests__/smallComponents.test.tsx index 5c53791..61de765 100644 --- a/apps/desktop/renderer/src/components/artist/__tests__/smallComponents.test.tsx +++ b/apps/desktop/renderer/src/components/artist/__tests__/smallComponents.test.tsx @@ -64,30 +64,75 @@ function makeAlbumTrack(overrides: Partial = {}): AlbumTrack { describe('TopSongRow', () => { it('renders track title', () => { - render(); + render( + + ); expect(screen.getByText('Come Together')).toBeInTheDocument(); }); it('renders 1-based index number', () => { - render(); + render( + + ); expect(screen.getByText('3')).toBeInTheDocument(); }); it('renders formatted duration', () => { - render(); + render( + + ); expect(screen.getByText('4:19')).toBeInTheDocument(); }); it('calls onAdd with raw item when + is clicked', () => { const onAdd = vi.fn(); const raw = { name: 'CT', type: 'TRACK' } as SonosItem; - render(); + render( + + ); fireEvent.click(screen.getByText('+')); expect(onAdd).toHaveBeenCalledWith(raw); }); it('shows ExplicitBadge when explicit is true', () => { - render(); + render( + + ); expect(screen.getByTitle('Explicit')).toBeInTheDocument(); }); }); @@ -169,7 +214,14 @@ function makeHeroTrack(overrides: Partial = {}): HeroTrack { describe('HeroTrackRow', () => { it('renders track title', () => { render( - + ); expect(screen.getByText('Hey Jude')).toBeInTheDocument(); }); @@ -177,7 +229,14 @@ describe('HeroTrackRow', () => { it('calls onClick when row is clicked', () => { const onClick = vi.fn(); render( - + ); fireEvent.click(screen.getByText('Hey Jude')); expect(onClick).toHaveBeenCalledWith(3, expect.any(Object)); @@ -186,7 +245,14 @@ describe('HeroTrackRow', () => { it('calls onAdd when + button is clicked', () => { const onAdd = vi.fn(); render( - + ); fireEvent.click(screen.getByText('+')); expect(onAdd).toHaveBeenCalled(); @@ -194,14 +260,28 @@ describe('HeroTrackRow', () => { it('renders formatted duration', () => { render( - + ); expect(screen.getByText('7:11')).toBeInTheDocument(); }); it('shows ExplicitBadge when explicit is true', () => { render( - + ); expect(screen.getByTitle('Explicit')).toBeInTheDocument(); }); diff --git a/apps/desktop/renderer/src/components/common/ContextMenu.tsx b/apps/desktop/renderer/src/components/common/ContextMenu.tsx index 2d693d1..ba25aa6 100644 --- a/apps/desktop/renderer/src/components/common/ContextMenu.tsx +++ b/apps/desktop/renderer/src/components/common/ContextMenu.tsx @@ -81,16 +81,9 @@ function PlaylistSubmenu({ const top = clamp(anchor.y, 8, window.innerHeight - 8); return createPortal( -
    e.stopPropagation()} - > +
    e.stopPropagation()}> {isLoading &&
    Loading…
    } - {!isLoading && allPlaylists.length === 0 && ( -
    No playlists yet
    - )} + {!isLoading && allPlaylists.length === 0 &&
    No playlists yet
    } {allPlaylists.map((pl) => (
    handleAdd(pl)}> ♪ @@ -101,13 +94,16 @@ function PlaylistSubmenu({ {allPlaylists.length > 0 &&
    }
    { onClose(); onOpenCreate(); }} + onClick={() => { + onClose(); + onOpenCreate(); + }} > + Create new playlist…
    , - document.body, + document.body ); } @@ -116,7 +112,12 @@ export function ContextMenuProvider({ children, displayName, onAddToQueue }: Pro const queryClient = useQueryClient(); const { showToast } = useToast(); const [menuState, setMenuState] = useState({ - open: false, x: 0, y: 0, payload: null, submenuOpen: false, submenuAnchor: null, + open: false, + x: 0, + y: 0, + payload: null, + submenuOpen: false, + submenuAnchor: null, }); const [createOpen, setCreateOpen] = useState(false); const [pendingTrack, setPendingTrack] = useState(null); @@ -205,49 +206,51 @@ export function ContextMenuProvider({ children, displayName, onAddToQueue }: Pro return ( {children} - {menuState.open && menuState.payload && createPortal( -
    e.stopPropagation()} - > - {menuState.payload.sonosItem && ( - <> -
    - ▶ - Play next -
    -
    - + - Add to queue + {menuState.open && + menuState.payload && + createPortal( +
    e.stopPropagation()} + > + {menuState.payload.sonosItem && ( + <> +
    + ▶ + Play next +
    +
    + + + Add to queue +
    +
    + + )} + {displayName && ( +
    + ❤️ + Add to Favourites
    -
    - - )} - {displayName && ( -
    - ❤️ - Add to Favourites + )} +
    + ♪ + Add to playlist + ▶
    - )} -
    - ♪ - Add to playlist - ▶ -
    - {menuState.payload.track.addedBy && ( - <> -
    -
    - 👤 - View {menuState.payload.track.addedBy}'s profile -
    - - )} -
    , - document.body, - )} + {menuState.payload.track.addedBy && ( + <> +
    +
    + 👤 + View {menuState.payload.track.addedBy}'s profile +
    + + )} +
    , + document.body + )} {menuState.open && menuState.submenuOpen && menuState.submenuAnchor && menuState.payload && ( { setCreateOpen(false); setPendingTrack(null); }} + onClose={() => { + setCreateOpen(false); + setPendingTrack(null); + }} /> )} @@ -313,17 +319,27 @@ export function CreatePlaylistDialog({ return createPortal(
    e.stopPropagation()} > @@ -332,15 +348,32 @@ export function CreatePlaylistDialog({ ref={inputRef} value={name} onChange={(e) => setName(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') onClose(); }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreate(); + if (e.key === 'Escape') onClose(); + }} placeholder="Playlist name" style={{ - background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 8, - padding: '8px 12px', color: 'var(--text)', fontSize: 14, outline: 'none', + background: 'var(--bg-2)', + border: '1px solid var(--border)', + borderRadius: 8, + padding: '8px 12px', + color: 'var(--text)', + fontSize: 14, + outline: 'none', fontFamily: 'inherit', }} /> -
    , - document.body, + document.body ); } diff --git a/apps/desktop/renderer/src/components/common/ExplicitBadge.tsx b/apps/desktop/renderer/src/components/common/ExplicitBadge.tsx index 220ccb3..5611878 100644 --- a/apps/desktop/renderer/src/components/common/ExplicitBadge.tsx +++ b/apps/desktop/renderer/src/components/common/ExplicitBadge.tsx @@ -1,5 +1,9 @@ import styles from './ExplicitBadge.module.css'; export function ExplicitBadge() { - return E; + return ( + + E + + ); } diff --git a/apps/desktop/renderer/src/components/common/MediaCard.tsx b/apps/desktop/renderer/src/components/common/MediaCard.tsx index de4b7c6..ae327ac 100644 --- a/apps/desktop/renderer/src/components/common/MediaCard.tsx +++ b/apps/desktop/renderer/src/components/common/MediaCard.tsx @@ -17,18 +17,26 @@ export function MediaCard({ name, sub, artUrl, explicit, circular, onAdd, onOpen return (
    - {cachedArt - ? - :
    ♪
    - } + {cachedArt ? :
    ♪
    } {onAdd && (
    - +
    )}
    -
    {name}{explicit && }
    +
    + {name} + {explicit && } +
    {sub &&
    {sub}
    }
    diff --git a/apps/desktop/renderer/src/components/common/MediaRow.module.css b/apps/desktop/renderer/src/components/common/MediaRow.module.css index c4e8433..81b62e1 100644 --- a/apps/desktop/renderer/src/components/common/MediaRow.module.css +++ b/apps/desktop/renderer/src/components/common/MediaRow.module.css @@ -7,54 +7,102 @@ transition: background 0.12s; cursor: default; } -.row:hover { background: var(--bg-2); } -.clickable { cursor: pointer; } -.draggable { cursor: grab; } -.draggable:active { cursor: grabbing; } +.row:hover { + background: var(--bg-2); +} +.clickable { + cursor: pointer; +} +.draggable { + cursor: grab; +} +.draggable:active { + cursor: grabbing; +} -.selected { background: rgba(255, 255, 255, 0.08); } -.selected:hover { background: rgba(255, 255, 255, 0.12) !important; } +.selected { + background: rgba(255, 255, 255, 0.08); +} +.selected:hover { + background: rgba(255, 255, 255, 0.12) !important; +} .leading { flex-shrink: 0; } -.artWrap { position: relative; width: 40px; height: 40px; flex-shrink: 0; } +.artWrap { + position: relative; + width: 40px; + height: 40px; + flex-shrink: 0; +} .art { - width: 40px; height: 40px; - border-radius: 4px; object-fit: cover; - background: var(--bg-1); display: block; + width: 40px; + height: 40px; + border-radius: 4px; + object-fit: cover; + background: var(--bg-1); + display: block; } .artPh { - width: 40px; height: 40px; - border-radius: 4px; background: var(--bg-1); - display: flex; align-items: center; justify-content: center; - color: var(--text-3); font-size: 14px; + width: 40px; + height: 40px; + border-radius: 4px; + background: var(--bg-1); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-3); + font-size: 14px; } .overlay { - position: absolute; inset: 0; border-radius: 4px; - background: rgba(0,0,0,0.6); - display: none; align-items: center; justify-content: center; - color: var(--text); font-size: 10px; pointer-events: none; + position: absolute; + inset: 0; + border-radius: 4px; + background: rgba(0, 0, 0, 0.6); + display: none; + align-items: center; + justify-content: center; + color: var(--text); + font-size: 10px; + pointer-events: none; +} +.playing .overlay { + display: flex; } -.playing .overlay { display: flex; } -.text { flex: 1; overflow: hidden; min-width: 0; } +.text { + flex: 1; + overflow: hidden; + min-width: 0; +} .name { - font-size: 13px; font-weight: 500; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; color: var(--text); - display: flex; align-items: center; gap: 4px; + display: flex; + align-items: center; + gap: 4px; +} +.playing .name { + color: #fff; + font-weight: 600; } -.playing .name { color: #fff; font-weight: 600; } .sub { - font-size: 11px; color: var(--text-2); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: 11px; + color: var(--text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; margin-top: 2px; } @@ -65,15 +113,27 @@ } .addBtn { - width: 26px; height: 26px; + width: 26px; + height: 26px; background: none; border: 1px solid var(--border); border-radius: 50%; - color: var(--text-3); cursor: pointer; - font-size: 16px; line-height: 1; - display: flex; align-items: center; justify-content: center; - flex-shrink: 0; transition: all 0.15s; + color: var(--text-3); + cursor: pointer; + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.15s; opacity: 0; } -.row:hover .addBtn { opacity: 1; } -.addBtn:hover { border-color: var(--border-2); color: var(--text-2); background: var(--bg-2); } +.row:hover .addBtn { + opacity: 1; +} +.addBtn:hover { + border-color: var(--border-2); + color: var(--text-2); + background: var(--bg-2); +} diff --git a/apps/desktop/renderer/src/components/common/MediaRow.tsx b/apps/desktop/renderer/src/components/common/MediaRow.tsx index 1c74724..5cd3b45 100644 --- a/apps/desktop/renderer/src/components/common/MediaRow.tsx +++ b/apps/desktop/renderer/src/components/common/MediaRow.tsx @@ -21,16 +21,31 @@ interface Props { } export function MediaRow({ - name, artUrl, explicit, isPlaying, isSelected, leading, subtitle, trailing, - onAdd, onClick, onDoubleClick, onHover, onContextMenu, draggable, onDragStart, + name, + artUrl, + explicit, + isPlaying, + isSelected, + leading, + subtitle, + trailing, + onAdd, + onClick, + onDoubleClick, + onHover, + onContextMenu, + draggable, + onDragStart, }: Props) { const cachedArt = useImage(artUrl); const classes = [ styles.row, - isPlaying ? styles.playing : '', - isSelected ? styles.selected : '', - draggable ? styles.draggable : (onClick ? styles.clickable : ''), - ].filter(Boolean).join(' '); + isPlaying ? styles.playing : '', + isSelected ? styles.selected : '', + draggable ? styles.draggable : onClick ? styles.clickable : '', + ] + .filter(Boolean) + .join(' '); return (
    {leading !== undefined &&
    {leading}
    }
    - {cachedArt - ? - :
    ♪
    - } + {cachedArt ? :
    ♪
    }
    ▶
    -
    {name}{explicit && }
    +
    + {name} + {explicit && } +
    {subtitle !== undefined &&
    {subtitle}
    }
    {trailing !== undefined &&
    {trailing}
    } {onAdd && ( - + )}
    ); diff --git a/apps/desktop/renderer/src/components/common/PlaylistCard.module.css b/apps/desktop/renderer/src/components/common/PlaylistCard.module.css index b6342c2..a7d4c80 100644 --- a/apps/desktop/renderer/src/components/common/PlaylistCard.module.css +++ b/apps/desktop/renderer/src/components/common/PlaylistCard.module.css @@ -10,7 +10,9 @@ width: 120px; text-align: left; } -.playlistCard:hover .playlistCardArt { opacity: 0.85; } +.playlistCard:hover .playlistCardArt { + opacity: 0.85; +} .playlistCardArt { width: 120px; diff --git a/apps/desktop/renderer/src/components/common/PlaylistCard.tsx b/apps/desktop/renderer/src/components/common/PlaylistCard.tsx index f612fde..06de4f0 100644 --- a/apps/desktop/renderer/src/components/common/PlaylistCard.tsx +++ b/apps/desktop/renderer/src/components/common/PlaylistCard.tsx @@ -14,12 +14,27 @@ export function PlaylistCard({ pl, displayName, onClick }: Props) { } - /> - ); + render(Artist Name} />); expect(screen.getByRole('button', { name: 'Artist Name' })).toBeInTheDocument(); }); diff --git a/apps/desktop/renderer/src/components/queue/DjUpcomingRow.tsx b/apps/desktop/renderer/src/components/queue/DjUpcomingRow.tsx index 2c4f67c..94d4106 100644 --- a/apps/desktop/renderer/src/components/queue/DjUpcomingRow.tsx +++ b/apps/desktop/renderer/src/components/queue/DjUpcomingRow.tsx @@ -30,11 +30,7 @@ export function DjUpcomingRow({ track, idle }: Props) { rows above without implying these can be dragged. */}

{scores.slice(0, 10).map((s, i) => (
- - + + + + {s.mainScore}/{currentGame.questions.length} · {s.bonusScore}/{currentGame.questions.length} @@ -376,8 +383,15 @@ export function QueuedlePanel() { {!rankings.isLoading && rankedRows.slice(0, 10).map((ranked, i) => (
- - + + + + {ranked.gamesPlayed} {ranked.gamesPlayed === 1 ? 'game' : 'games'} ·{' '} @@ -632,11 +646,7 @@ export function QueuedlePanel() { )} {phase === 'rank-change' && afterRanking && ( - setPhase('summary')} - /> + setPhase('summary')} /> )} {phase === 'summary' && localScore && ( diff --git a/apps/desktop/renderer/src/components/queuedle/QueuedleQuestionCard.tsx b/apps/desktop/renderer/src/components/queuedle/QueuedleQuestionCard.tsx index f94e092..d7721af 100644 --- a/apps/desktop/renderer/src/components/queuedle/QueuedleQuestionCard.tsx +++ b/apps/desktop/renderer/src/components/queuedle/QueuedleQuestionCard.tsx @@ -42,8 +42,7 @@ function Side({ pickedSide: 'left' | 'right' | null; onPick: (side: 'left' | 'right') => void; }) { - const pickLabel = - item.category === 'track' ? 'Pick song' : item.category === 'artist' ? 'Pick artist' : 'Pick album'; + const pickLabel = item.category === 'track' ? 'Pick song' : item.category === 'artist' ? 'Pick artist' : 'Pick album'; const isWrongPick = revealed && pickedSide === side && !isWinner; const isCorrectPick = revealed && pickedSide === side && isWinner; @@ -78,11 +77,7 @@ function Side({ <> {isCarryover && }
-
diff --git a/apps/desktop/renderer/src/components/queuedle/QueuedleRankChange.tsx b/apps/desktop/renderer/src/components/queuedle/QueuedleRankChange.tsx index 0659b15..13c1b4d 100644 --- a/apps/desktop/renderer/src/components/queuedle/QueuedleRankChange.tsx +++ b/apps/desktop/renderer/src/components/queuedle/QueuedleRankChange.tsx @@ -52,18 +52,11 @@ export function QueuedleRankChange({ before, after, onContinue }: Props) { const prevTierIcon = before ? getGameRankIcon(before.tierKey) : null; const deltaLabel = delta > 0 ? `+${delta}` : `${delta}`; - const deltaClass = - delta > 0 - ? styles.rankDeltaUp - : delta < 0 - ? styles.rankDeltaDown - : styles.rankDeltaFlat; + const deltaClass = delta > 0 ? styles.rankDeltaUp : delta < 0 ? styles.rankDeltaDown : styles.rankDeltaFlat; return (
-

- {before ? 'Rank update' : 'Welcome to the ranks'} -

+

{before ? 'Rank update' : 'Welcome to the ranks'}

{tierChanged && prevTierIcon && ( @@ -71,12 +64,8 @@ export function QueuedleRankChange({ before, after, onContinue }: Props) {
)} - {tierChanged && prevTierIcon && ( -
→
- )} -
+ {tierChanged && prevTierIcon &&
→
} +
{tierIcon ? ( ) : ( diff --git a/apps/desktop/renderer/src/components/queuedle/QueuedleSummary.tsx b/apps/desktop/renderer/src/components/queuedle/QueuedleSummary.tsx index ab06808..a4672ea 100644 --- a/apps/desktop/renderer/src/components/queuedle/QueuedleSummary.tsx +++ b/apps/desktop/renderer/src/components/queuedle/QueuedleSummary.tsx @@ -23,7 +23,11 @@ function ReviewRow({ question: q, guess, qStat }: ReviewRowProps) { return (
- {leftArt ? {q.left.name} :
} + {leftArt ? ( + {q.left.name} + ) : ( +
+ )} {q.left.name} {q.left.count.toLocaleString()} @@ -39,13 +43,15 @@ function ReviewRow({ question: q, guess, qStat }: ReviewRowProps) { {q.right.name} {q.right.count.toLocaleString()} - {rightArt ? {q.right.name} :
} + {rightArt ? ( + {q.right.name} + ) : ( +
+ )}
{qStat && (
- {correctPct !== undefined && ( - {correctPct}% got this right - )} + {correctPct !== undefined && {correctPct}% got this right} {qStat.bonusOptions.length > 0 && ( {qStat.bonusOptions.map((o) => `${o.name} ${o.pct}%`).join(' · ')} @@ -92,11 +98,15 @@ export function QueuedleSummary({
out of {maxTotal}
-
{mainScore}/{maxMain}
+
+ {mainScore}/{maxMain} +
Higher or lower
-
{bonusScore}/{maxBonus}
+
+ {bonusScore}/{maxBonus} +
Top queuer
diff --git a/apps/desktop/renderer/src/components/queuedle/ScoreDistribution.tsx b/apps/desktop/renderer/src/components/queuedle/ScoreDistribution.tsx index b94420c..f3a4b0c 100644 --- a/apps/desktop/renderer/src/components/queuedle/ScoreDistribution.tsx +++ b/apps/desktop/renderer/src/components/queuedle/ScoreDistribution.tsx @@ -10,7 +10,9 @@ interface Props { export function ScoreDistribution({ scores, maxScore, playerScore, title }: Props) { const total = scores.length; const counts = Array.from({ length: maxScore + 1 }, () => 0); - scores.forEach((s) => { if (s >= 0 && s <= maxScore) counts[s]++; }); + scores.forEach((s) => { + if (s >= 0 && s <= maxScore) counts[s]++; + }); const buckets = counts.map((count, score) => ({ score, pct: total > 0 ? (count / total) * 100 : 0, @@ -19,21 +21,23 @@ export function ScoreDistribution({ scores, maxScore, playerScore, title }: Prop return (

{title}

-
- {buckets.map((b) => ( -
-
-
+
+ {buckets.map((b) => ( +
+
+
+
+ + {b.score} +
- - {b.score} - -
- ))} -
+ ))} +
); } diff --git a/apps/desktop/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx b/apps/desktop/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx index 43ea589..dcd6ac2 100644 --- a/apps/desktop/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx +++ b/apps/desktop/renderer/src/components/queuedle/__tests__/QueuedlePanel.test.tsx @@ -21,13 +21,13 @@ vi.mock('@/hooks/useDailyGame', () => ({ useGameRankings: (userName?: string | null, enabled?: boolean) => mockUseGameRankings(userName, enabled), useMyScore: (gameId: string | null, userName: string | null | undefined) => mockUseMyScore(gameId, userName), useGameStats: (gameId: string | null) => mockUseGameStats(gameId), - computeQueuedleRating: (averagePercent: number) => - Math.round(Math.max(0, Math.min(100, averagePercent)) * 30), + computeQueuedleRating: (averagePercent: number) => Math.round(Math.max(0, Math.min(100, averagePercent)) * 30), getGameRankTier: (averagePercent: number, gamesPlayed: number) => { if (gamesPlayed < 3) return { key: 'provisional', name: 'Provisional', isProvisional: true }; if (averagePercent >= 85) return { key: 'playlist-prophet', name: 'Playlist Prophet', isProvisional: false }; if (averagePercent >= 70) return { key: 'algorithm-whisperer', name: 'Algorithm Whisperer', isProvisional: false }; - if (averagePercent >= 55) return { key: 'aux-cable-apprentice', name: 'Aux Cable Apprentice', isProvisional: false }; + if (averagePercent >= 55) + return { key: 'aux-cable-apprentice', name: 'Aux Cable Apprentice', isProvisional: false }; if (averagePercent >= 40) return { key: 'background-bopper', name: 'Background Bopper', isProvisional: false }; return { key: 'skip-button-survivor', name: 'Skip Button Survivor', isProvisional: false }; }, diff --git a/apps/desktop/renderer/src/components/search/ArtistCircle.tsx b/apps/desktop/renderer/src/components/search/ArtistCircle.tsx index b06da8c..0dfc7bc 100644 --- a/apps/desktop/renderer/src/components/search/ArtistCircle.tsx +++ b/apps/desktop/renderer/src/components/search/ArtistCircle.tsx @@ -9,15 +9,11 @@ interface Props { } export function ArtistCircle({ artist, onOpen }: Props) { - const art = useImage(getItemArt(artist)); + const art = useImage(getItemArt(artist)); const name = (artist.title ?? artist.name ?? '') as string; return (
onOpen(artist)}> -
- {art - ? - :
{name[0]}
} -
+
{art ? :
{name[0]}
}
{name}
); diff --git a/apps/desktop/renderer/src/components/search/SearchResults.module.css b/apps/desktop/renderer/src/components/search/SearchResults.module.css index 098e3b5..b7384e2 100644 --- a/apps/desktop/renderer/src/components/search/SearchResults.module.css +++ b/apps/desktop/renderer/src/components/search/SearchResults.module.css @@ -18,7 +18,9 @@ overflow-x: auto; padding-bottom: 4px; } -.artistsRow::-webkit-scrollbar { height: 3px; } +.artistsRow::-webkit-scrollbar { + height: 3px; +} /* ── Albums row ── */ .searchAlbumsRow { @@ -27,7 +29,9 @@ overflow-x: auto; padding-bottom: 4px; } -.searchAlbumsRow::-webkit-scrollbar { height: 3px; } +.searchAlbumsRow::-webkit-scrollbar { + height: 3px; +} /* ── Album card ── */ .searchAlbumCard { @@ -38,7 +42,9 @@ border-radius: var(--r-md); transition: background 0.15s; } -.searchAlbumCard:hover { background: var(--bg-2); } +.searchAlbumCard:hover { + background: var(--bg-2); +} .searchAlbumArt { position: relative; @@ -49,51 +55,95 @@ background: var(--bg-1); margin-bottom: 8px; } -.searchAlbumArt img { width: 100%; height: 100%; object-fit: cover; display: block; } +.searchAlbumArt img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} .searchAlbumArtPh { - width: 100%; height: 100%; - display: flex; align-items: center; justify-content: center; - font-size: 28px; color: var(--text-3); + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + color: var(--text-3); } .searchAlbumAddBtn { position: absolute; - inset: 0; margin: auto; - width: 36px; height: 36px; - border-radius: 50%; border: none; - background: rgba(0, 0, 0, 0.7); color: #fff; - font-size: 22px; line-height: 1; - display: flex; align-items: center; justify-content: center; + inset: 0; + margin: auto; + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.7); + color: #fff; + font-size: 22px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; cursor: pointer; - opacity: 0; transition: opacity 0.15s, transform 0.15s; + opacity: 0; + transition: + opacity 0.15s, + transform 0.15s; backdrop-filter: blur(4px); } -.searchAlbumCard:hover .searchAlbumAddBtn { opacity: 1; } -.searchAlbumAddBtn:hover { transform: scale(1.1); background: rgba(0, 0, 0, 0.85); } +.searchAlbumCard:hover .searchAlbumAddBtn { + opacity: 1; +} +.searchAlbumAddBtn:hover { + transform: scale(1.1); + background: rgba(0, 0, 0, 0.85); +} .searchAlbumTitle { - font-size: 12px; font-weight: 500; color: var(--text); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: 12px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .searchAlbumSub { - font-size: 11px; color: var(--text-3); margin-top: 2px; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: 11px; + color: var(--text-3); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } /* ── Tracks list ── */ -.tracksList { display: flex; flex-direction: column; } +.tracksList { + display: flex; + flex-direction: column; +} /* ── Artist link buttons within track rows ── */ .artistBtn { - background: none; border: none; padding: 0; - font-size: inherit; color: var(--text-2); - cursor: pointer; font-family: inherit; + background: none; + border: none; + padding: 0; + font-size: inherit; + color: var(--text-2); + cursor: pointer; + font-family: inherit; transition: color 0.12s; } -.artistBtn:hover { color: var(--text); text-decoration: underline; } +.artistBtn:hover { + color: var(--text); + text-decoration: underline; +} /* ── Misc ── */ .msg { - color: var(--text-3); font-size: 14px; - text-align: center; padding: 56px 0; + color: var(--text-3); + font-size: 14px; + text-align: center; + padding: 56px 0; } diff --git a/apps/desktop/renderer/src/components/search/SearchResults.tsx b/apps/desktop/renderer/src/components/search/SearchResults.tsx index 41dd0f7..d3283f0 100644 --- a/apps/desktop/renderer/src/components/search/SearchResults.tsx +++ b/apps/desktop/renderer/src/components/search/SearchResults.tsx @@ -10,15 +10,31 @@ import { useTrackContextMenu } from '../common/ContextMenu'; import type { SonosItem, SonosArtist, SonosItemId } from '../../types/sonos'; import styles from './SearchResults.module.css'; -function SearchAlbumCard({ album, onOpen, onAdd }: { album: SonosItem; onOpen: (item: SonosItem) => void; onAdd: () => void }) { - const art = useImage(getItemArt(album)); +function SearchAlbumCard({ + album, + onOpen, + onAdd, +}: { + album: SonosItem; + onOpen: (item: SonosItem) => void; + onAdd: () => void; +}) { + const art = useImage(getItemArt(album)); const name = (album.title ?? album.name ?? '') as string; - const sub = browseSub(album); + const sub = browseSub(album); return (
onOpen(album)}>
{art ? :
♪
} - +
{name}
{sub &&
{sub}
} @@ -35,14 +51,15 @@ export function SearchResults({ }) { const openItem = useOpenItem(); const { showTrackMenu } = useTrackContextMenu(); - const [selected, setSelected] = useState>(new Set()); - const lastSelected = useRef(null); + const [selected, setSelected] = useState>(new Set()); + const lastSelected = useRef(null); function buildTrackPayload(item: SonosItem): PlaylistTrack { const rid = (typeof item.id === 'object' ? item.id : item.resource?.id) as SonosItemId | undefined; - const artist = typeof item.artist === 'string' ? item.artist - : (item.artist as SonosArtist | undefined)?.name - ?? item.artists?.[0]?.name ?? ''; + const artist = + typeof item.artist === 'string' + ? item.artist + : ((item.artist as SonosArtist | undefined)?.name ?? item.artists?.[0]?.name ?? ''); const albumName = typeof item.album === 'string' ? item.album : item.album?.name; return { uri: rid?.objectId ?? item.objectId ?? '', @@ -57,32 +74,49 @@ export function SearchResults({ }; } - const topArtist = results.length > 0 && isArtist(results[0]) ? results[0] : null; - const artists = results.filter(isArtist); + const topArtist = results.length > 0 && isArtist(results[0]) ? results[0] : null; + const artists = results.filter(isArtist); const remainingArtists = topArtist ? artists.slice(1) : artists; - const albums = results.filter(isAlbum); - const tracks = results.filter(isTrack); - const others = results.filter(r => !isArtist(r) && !isAlbum(r) && !isTrack(r)); + const albums = results.filter(isAlbum); + const tracks = results.filter(isTrack); + const others = results.filter((r) => !isArtist(r) && !isAlbum(r) && !isTrack(r)); function handleTrackClick(i: number, e: React.MouseEvent) { if (e.shiftKey && lastSelected.current !== null) { const lo = Math.min(lastSelected.current, i); const hi = Math.max(lastSelected.current, i); - setSelected(prev => { const next = new Set(prev); for (let j = lo; j <= hi; j++) next.add(j); return next; }); + setSelected((prev) => { + const next = new Set(prev); + for (let j = lo; j <= hi; j++) next.add(j); + return next; + }); } else if (e.ctrlKey || e.metaKey) { - setSelected(prev => { const next = new Set(prev); if (next.has(i)) next.delete(i); else next.add(i); return next; }); + setSelected((prev) => { + const next = new Set(prev); + if (next.has(i)) next.delete(i); + else next.add(i); + return next; + }); lastSelected.current = i; } else { - if (selected.size === 1 && selected.has(i)) { setSelected(new Set()); lastSelected.current = null; } - else { setSelected(new Set([i])); lastSelected.current = i; } + if (selected.size === 1 && selected.has(i)) { + setSelected(new Set()); + lastSelected.current = null; + } else { + setSelected(new Set([i])); + lastSelected.current = i; + } } } function handleDragStart(e: React.DragEvent, i: number) { const toMove = selected.has(i) ? [...selected].sort((a, b) => a - b) : [i]; - if (!selected.has(i)) { setSelected(new Set([i])); lastSelected.current = i; } + if (!selected.has(i)) { + setSelected(new Set([i])); + lastSelected.current = i; + } e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map(idx => tracks[idx]))); + e.dataTransfer.setData('application/sonos-item-list', JSON.stringify(toMove.map((idx) => tracks[idx]))); createDragGhost(toMove.length > 1 ? `${toMove.length} tracks` : getName(tracks[i]), e.dataTransfer); } @@ -90,9 +124,7 @@ export function SearchResults({ return ( <> - {topArtist && ( - - )} + {topArtist && } {remainingArtists.length > 0 && (
@@ -121,35 +153,49 @@ export function SearchResults({

Songs

{ setSelected(new Set()); lastSelected.current = null; }} + onClick={() => { + setSelected(new Set()); + lastSelected.current = null; + }} > {tracks.map((item, i) => { const artists = (item.artists ?? []) as SonosArtist[]; - const subtitle = artists.length > 0 ? ( - <> - {artists.map((a, ai) => { - const artistItem: SonosItem | null = a.id?.objectId ? { - type: 'ARTIST', - name: a.name, - resource: { type: 'ARTIST', id: { objectId: a.id.objectId, serviceId: a.id.serviceId, accountId: a.id.accountId } }, - } : null; - return ( - - {ai > 0 && ', '} - {artistItem ? ( - - ) : a.name} - - ); - })} - - ) : undefined; + const subtitle = + artists.length > 0 ? ( + <> + {artists.map((a, ai) => { + const artistItem: SonosItem | null = a.id?.objectId + ? { + type: 'ARTIST', + name: a.name, + resource: { + type: 'ARTIST', + id: { objectId: a.id.objectId, serviceId: a.id.serviceId, accountId: a.id.accountId }, + }, + } + : null; + return ( + + {ai > 0 && ', '} + {artistItem ? ( + + ) : ( + a.name + )} + + ); + })} + + ) : undefined; return ( onAddToQueue(item)} - onClick={e => { e.stopPropagation(); handleTrackClick(i, e); }} - onContextMenu={e => showTrackMenu({ track: buildTrackPayload(item) }, e)} + onClick={(e) => { + e.stopPropagation(); + handleTrackClick(i, e); + }} + onContextMenu={(e) => showTrackMenu({ track: buildTrackPayload(item) }, e)} draggable - onDragStart={e => handleDragStart(e, i)} + onDragStart={(e) => handleDragStart(e, i)} /> ); })} diff --git a/apps/desktop/renderer/src/components/search/__tests__/SearchResults.test.tsx b/apps/desktop/renderer/src/components/search/__tests__/SearchResults.test.tsx index 9886e6e..fa88a84 100644 --- a/apps/desktop/renderer/src/components/search/__tests__/SearchResults.test.tsx +++ b/apps/desktop/renderer/src/components/search/__tests__/SearchResults.test.tsx @@ -7,9 +7,7 @@ const mockOpenItem = vi.fn(); vi.mock('../../../hooks/useOpenItem', () => ({ useOpenItem: () => mockOpenItem })); vi.mock('../../../hooks/useImage', () => ({ useImage: () => null })); vi.mock('../../artist/ArtistHero', () => ({ - ArtistHero: ({ artist }: { artist: SonosItem }) => ( -
{String(artist.name ?? '')}
- ), + ArtistHero: ({ artist }: { artist: SonosItem }) =>
{String(artist.name ?? '')}
, })); vi.mock('../ArtistCircle', () => ({ ArtistCircle: ({ artist }: { artist: SonosItem }) => ( @@ -192,9 +190,7 @@ describe('SearchResults — artist subtitles', () => { const btn = screen.getByRole('button', { name: 'My Artist' }); expect(btn).toBeInTheDocument(); fireEvent.click(btn); - expect(mockOpenItem).toHaveBeenCalledWith( - expect.objectContaining({ type: 'ARTIST' }) - ); + expect(mockOpenItem).toHaveBeenCalledWith(expect.objectContaining({ type: 'ARTIST' })); }); it('renders artist name as plain text when artist has no id', () => { @@ -268,10 +264,7 @@ describe('drag behaviour', () => { }; fireEvent.dragStart(row, { dataTransfer: dt }); - expect(dt.setData).toHaveBeenCalledWith( - 'application/sonos-item-list', - expect.stringContaining('Drag Me') - ); + expect(dt.setData).toHaveBeenCalledWith('application/sonos-item-list', expect.stringContaining('Drag Me')); }); it('drag data is JSON-parseable array', () => { @@ -279,7 +272,14 @@ describe('drag behaviour', () => { const row = getTrackRows(container)[0]; let capturedData = ''; - const dt = { effectAllowed: '', setData: vi.fn((_, v) => { capturedData = v; }), setDragImage: vi.fn(), types: [] as string[] }; + const dt = { + effectAllowed: '', + setData: vi.fn((_, v) => { + capturedData = v; + }), + setDragImage: vi.fn(), + types: [] as string[], + }; fireEvent.dragStart(row, { dataTransfer: dt }); const parsed = JSON.parse(capturedData) as unknown[]; diff --git a/apps/desktop/renderer/src/components/skinny/SkinnyShell.tsx b/apps/desktop/renderer/src/components/skinny/SkinnyShell.tsx index 77c5bcd..fceca02 100644 --- a/apps/desktop/renderer/src/components/skinny/SkinnyShell.tsx +++ b/apps/desktop/renderer/src/components/skinny/SkinnyShell.tsx @@ -67,7 +67,7 @@ export function SkinnyShell({ currentQueueItemId={playback.queueItemId} positionMs={playback.positionMs} currentTrackDurationMs={playback.durationMs} - groupName={groups.find(g => g.id === activeGroupId)?.name ?? null} + groupName={groups.find((g) => g.id === activeGroupId)?.name ?? null} onRefresh={reloadQueue} onError={showToast} onAddToQueue={() => {}} diff --git a/apps/desktop/renderer/src/components/skinny/SkinnyTransport.tsx b/apps/desktop/renderer/src/components/skinny/SkinnyTransport.tsx index bd5d6e4..be2ab3e 100644 --- a/apps/desktop/renderer/src/components/skinny/SkinnyTransport.tsx +++ b/apps/desktop/renderer/src/components/skinny/SkinnyTransport.tsx @@ -16,10 +16,8 @@ interface Props { * prev / play-pause / next. */ export function SkinnyTransport({ np, isAuthed }: Props) { - const { - displayTrack, displayArtist, cachedArt, progressPct, durationMs, - isPlaying, elapsedLabel, durationLabel, - } = np; + const { displayTrack, displayArtist, cachedArt, progressPct, durationMs, isPlaying, elapsedLabel, durationLabel } = + np; const refresh = () => getActiveProvider().refreshPlayback(); @@ -27,7 +25,9 @@ export function SkinnyTransport({ np, isAuthed }: Props) { if (!durationMs) return; const rect = e.currentTarget.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - getActiveProvider().seek(Math.floor(pct * durationMs)).then(refresh); + getActiveProvider() + .seek(Math.floor(pct * durationMs)) + .then(refresh); }; return ( @@ -39,12 +39,7 @@ export function SkinnyTransport({ np, isAuthed }: Props) {
{displayTrack || '—'}
{displayArtist || ''}
-
+
diff --git a/apps/desktop/renderer/src/context/AppContext.tsx b/apps/desktop/renderer/src/context/AppContext.tsx index c392099..c5fe9b0 100644 --- a/apps/desktop/renderer/src/context/AppContext.tsx +++ b/apps/desktop/renderer/src/context/AppContext.tsx @@ -11,7 +11,7 @@ interface AppContextValue { const AppContext = createContext(null); export function AppProvider({ children }: { children: ReactNode }) { - const [isAuthed, setIsAuthed] = useState(false); + const [isAuthed, setIsAuthed] = useState(false); const [activeGroupId, setActiveGroupIdState] = useState(null); const setActiveGroupId = useCallback((id: string) => { diff --git a/apps/desktop/renderer/src/context/__tests__/AppContext.test.tsx b/apps/desktop/renderer/src/context/__tests__/AppContext.test.tsx index fcd62a4..12822aa 100644 --- a/apps/desktop/renderer/src/context/__tests__/AppContext.test.tsx +++ b/apps/desktop/renderer/src/context/__tests__/AppContext.test.tsx @@ -16,24 +16,44 @@ function Consumer() { describe('AppContext', () => { it('provides default isAuthed=false', () => { - render(); + render( + + + + ); expect(screen.getByTestId('authed').textContent).toBe('false'); }); it('provides default activeGroupId=null', () => { - render(); + render( + + + + ); expect(screen.getByTestId('group').textContent).toBe('null'); }); it('setIsAuthed updates the value', () => { - render(); - act(() => { screen.getByRole('button', { name: 'auth' }).click(); }); + render( + + + + ); + act(() => { + screen.getByRole('button', { name: 'auth' }).click(); + }); expect(screen.getByTestId('authed').textContent).toBe('true'); }); it('setActiveGroupId updates the value', () => { - render(); - act(() => { screen.getByRole('button', { name: 'group' }).click(); }); + render( + + + + ); + act(() => { + screen.getByRole('button', { name: 'group' }).click(); + }); expect(screen.getByTestId('group').textContent).toBe('g-1'); }); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useArtistBrowse.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useArtistBrowse.test.ts index caffa07..c9bb7c0 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useArtistBrowse.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useArtistBrowse.test.ts @@ -13,18 +13,20 @@ function makeArtistResponse(overrides: Record = {}) { title: 'The Beatles', images: { tile1x1: 'https://art/beatles.jpg' }, sections: { - items: [{ - items: [ - { type: 'ITEM_ALBUM', title: 'Abbey Road', resource: { id: { objectId: 'alb-1' } } }, - { type: 'ITEM_ALBUM', title: 'Let It Be', resource: { id: { objectId: 'alb-2' } } }, - { type: 'ITEM_PLAYLIST', title: 'The Beatles Radio', resource: { id: { objectId: 'pl-radio' } } }, - { - type: 'ITEM_PLAYLIST', - title: 'Top Songs', - resource: { id: { objectId: 'pl-top', serviceId: 'svc', accountId: 'acc' } }, - }, - ], - }], + items: [ + { + items: [ + { type: 'ITEM_ALBUM', title: 'Abbey Road', resource: { id: { objectId: 'alb-1' } } }, + { type: 'ITEM_ALBUM', title: 'Let It Be', resource: { id: { objectId: 'alb-2' } } }, + { type: 'ITEM_PLAYLIST', title: 'The Beatles Radio', resource: { id: { objectId: 'pl-radio' } } }, + { + type: 'ITEM_PLAYLIST', + title: 'Top Songs', + resource: { id: { objectId: 'pl-top', serviceId: 'svc', accountId: 'acc' } }, + }, + ], + }, + ], }, ...overrides, }; @@ -70,11 +72,11 @@ describe('artistQueryOptions — basic parsing', () => { it('returns empty topSongs when no top songs playlist', async () => { const noTopSongs = makeArtistResponse({ sections: { - items: [{ - items: [ - { type: 'ITEM_ALBUM', title: 'Abbey Road', resource: { id: { objectId: 'alb-1' } } }, - ], - }], + items: [ + { + items: [{ type: 'ITEM_ALBUM', title: 'Abbey Road', resource: { id: { objectId: 'alb-1' } } }], + }, + ], }, }); mockFetch.mockResolvedValueOnce({ data: noTopSongs }); @@ -85,15 +87,15 @@ describe('artistQueryOptions — basic parsing', () => { it('throws when response has an error', async () => { mockFetch.mockResolvedValueOnce({ data: null, error: 'Not found' }); - await expect( - artistQueryOptions('art-1', 'svc', 'acc', undefined).queryFn() - ).rejects.toThrow('Not found'); + await expect(artistQueryOptions('art-1', 'svc', 'acc', undefined).queryFn()).rejects.toThrow('Not found'); }); it('returns null imageUrl when images are missing', async () => { const noImagesNoTopSongs = makeArtistResponse({ images: undefined, - sections: { items: [{ items: [{ type: 'ITEM_ALBUM', title: 'Abbey Road', resource: { id: { objectId: 'alb-1' } } }] }] }, + sections: { + items: [{ items: [{ type: 'ITEM_ALBUM', title: 'Abbey Road', resource: { id: { objectId: 'alb-1' } } }] }], + }, }); mockFetch.mockResolvedValueOnce({ data: noImagesNoTopSongs }); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useAttribution.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useAttribution.test.ts index f19905f..ad0bfd4 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useAttribution.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useAttribution.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useAttribution, attributionContentKey } from '../useAttribution'; -const mockOnAttributionMap = vi.mocked(window.sonos.onAttributionMap); +const mockOnAttributionMap = vi.mocked(window.sonos.onAttributionMap); const mockOnAttributionEvent = vi.mocked(window.sonos.onAttributionEvent); beforeEach(() => { @@ -24,7 +24,10 @@ describe('useAttribution', () => { it('updates map when onAttributionMap fires', () => { let mapCb: ((map: AttributionMap) => void) | undefined; - mockOnAttributionMap.mockImplementation((cb) => { mapCb = cb; return () => {}; }); + mockOnAttributionMap.mockImplementation((cb) => { + mapCb = cb; + return () => {}; + }); const { result } = renderHook(() => useAttribution()); act(() => { @@ -36,8 +39,14 @@ describe('useAttribution', () => { it('merges new events into existing map', () => { let mapCb: ((map: AttributionMap) => void) | undefined; let eventCb: ((event: AttributionEvent) => void) | undefined; - mockOnAttributionMap.mockImplementation((cb) => { mapCb = cb; return () => {}; }); - mockOnAttributionEvent.mockImplementation((cb) => { eventCb = cb; return () => {}; }); + mockOnAttributionMap.mockImplementation((cb) => { + mapCb = cb; + return () => {}; + }); + mockOnAttributionEvent.mockImplementation((cb) => { + eventCb = cb; + return () => {}; + }); const { result } = renderHook(() => useAttribution()); act(() => { @@ -53,7 +62,10 @@ describe('useAttribution', () => { it('calls onRemoteQueue when an attribution event fires', () => { let eventCb: ((event: AttributionEvent) => void) | undefined; - mockOnAttributionEvent.mockImplementation((cb) => { eventCb = cb; return () => {}; }); + mockOnAttributionEvent.mockImplementation((cb) => { + eventCb = cb; + return () => {}; + }); const onRemoteQueue = vi.fn(); renderHook(() => useAttribution(onRemoteQueue)); @@ -65,11 +77,21 @@ describe('useAttribution', () => { it('indexes live events under a title+artist content key (issue #84)', () => { let eventCb: ((event: AttributionEvent) => void) | undefined; - mockOnAttributionEvent.mockImplementation((cb) => { eventCb = cb; return () => {}; }); + mockOnAttributionEvent.mockImplementation((cb) => { + eventCb = cb; + return () => {}; + }); const { result } = renderHook(() => useAttribution()); act(() => { - eventCb?.({ type: 'queued', uri: 'obj:1', user: 'dana', timestamp: 9, trackName: 'Svefn-g-englar', artist: 'Sigur Rós' }); + eventCb?.({ + type: 'queued', + uri: 'obj:1', + user: 'dana', + timestamp: 9, + trackName: 'Svefn-g-englar', + artist: 'Sigur Rós', + }); }); // A re-keyed queue row (different objectId, same title+artist) still resolves. @@ -85,7 +107,10 @@ describe('useAttribution', () => { it('does not index the persisted snapshot under content keys', () => { let mapCb: ((map: AttributionMap) => void) | undefined; - mockOnAttributionMap.mockImplementation((cb) => { mapCb = cb; return () => {}; }); + mockOnAttributionMap.mockImplementation((cb) => { + mapCb = cb; + return () => {}; + }); const { result } = renderHook(() => useAttribution()); act(() => { @@ -100,7 +125,7 @@ describe('useAttribution', () => { }); it('calls unsubscribe functions on unmount', () => { - const unsubMap = vi.fn(); + const unsubMap = vi.fn(); const unsubEvent = vi.fn(); mockOnAttributionMap.mockReturnValue(unsubMap); mockOnAttributionEvent.mockReturnValue(unsubEvent); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useAuth.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useAuth.test.ts index ab9b980..335d327 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useAuth.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useAuth.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useAuth } from '../useAuth'; -const mockOnAuthReady = vi.mocked(window.sonos.onAuthReady); +const mockOnAuthReady = vi.mocked(window.sonos.onAuthReady); const mockOnAuthExpired = vi.mocked(window.sonos.onAuthExpired); beforeEach(() => { @@ -19,31 +19,46 @@ describe('useAuth', () => { it('sets true when onAuthReady fires', () => { let readyCb: (() => void) | undefined; - mockOnAuthReady.mockImplementation((cb) => { readyCb = cb; return () => {}; }); + mockOnAuthReady.mockImplementation((cb) => { + readyCb = cb; + return () => {}; + }); const { result } = renderHook(() => useAuth()); expect(result.current).toBe(false); - act(() => { readyCb?.(); }); + act(() => { + readyCb?.(); + }); expect(result.current).toBe(true); }); it('sets false when onAuthExpired fires after auth', () => { let readyCb: (() => void) | undefined; let expiredCb: (() => void) | undefined; - mockOnAuthReady.mockImplementation((cb) => { readyCb = cb; return () => {}; }); - mockOnAuthExpired.mockImplementation((cb) => { expiredCb = cb; return () => {}; }); + mockOnAuthReady.mockImplementation((cb) => { + readyCb = cb; + return () => {}; + }); + mockOnAuthExpired.mockImplementation((cb) => { + expiredCb = cb; + return () => {}; + }); const { result } = renderHook(() => useAuth()); - act(() => { readyCb?.(); }); + act(() => { + readyCb?.(); + }); expect(result.current).toBe(true); - act(() => { expiredCb?.(); }); + act(() => { + expiredCb?.(); + }); expect(result.current).toBe(false); }); it('calls both unsub functions on unmount', () => { - const unsubReady = vi.fn(); + const unsubReady = vi.fn(); const unsubExpired = vi.fn(); mockOnAuthReady.mockReturnValue(unsubReady); mockOnAuthExpired.mockReturnValue(unsubExpired); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useAutoplay.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useAutoplay.test.ts index c535413..46f3201 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useAutoplay.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useAutoplay.test.ts @@ -23,7 +23,11 @@ beforeEach(() => { describe('useAutoplay', () => { it('reports the queue and now-playing to the coordinator', async () => { vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ - enabled: true, leaseHeld: false, enqueue: null, upcoming: [track], fillerUri: null, + enabled: true, + leaseHeld: false, + enqueue: null, + upcoming: [track], + fillerUri: null, }); renderHook(() => @@ -32,7 +36,7 @@ describe('useAutoplay', () => { items: [item('a'), item('b')], nowPlayingUri: 'a', onEnqueue: vi.fn(), - }), + }) ); await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); @@ -50,10 +54,18 @@ describe('useAutoplay', () => { */ it('sends name keys alongside uris, index-aligned', async () => { vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ - enabled: true, leaseHeld: false, enqueue: null, upcoming: [], fillerUri: null, + enabled: true, + leaseHeld: false, + enqueue: null, + upcoming: [], + fillerUri: null, }); vi.mocked(window.sonos.fetchDjSet).mockResolvedValue({ - tracks: [], artists: [], listeners: [], artistsConsidered: 0, inferredListeners: true, + tracks: [], + artists: [], + listeners: [], + artistsConsidered: 0, + inferredListeners: true, }); renderHook(() => @@ -62,7 +74,7 @@ describe('useAutoplay', () => { items: [item('a'), item('b')], nowPlayingUri: 'a', onEnqueue: vi.fn(), - }), + }) ); await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); @@ -77,12 +89,14 @@ describe('useAutoplay', () => { it('only enqueues when it holds the lease', async () => { const onEnqueue = vi.fn(); vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ - enabled: true, leaseHeld: false, enqueue: track, upcoming: [track], fillerUri: null, + enabled: true, + leaseHeld: false, + enqueue: track, + upcoming: [track], + fillerUri: null, }); - renderHook(() => - useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue }), - ); + renderHook(() => useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue })); await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); expect(onEnqueue).not.toHaveBeenCalled(); @@ -91,12 +105,14 @@ describe('useAutoplay', () => { it('enqueues the coordinator’s pick when it does hold the lease', async () => { const onEnqueue = vi.fn(); vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ - enabled: true, leaseHeld: true, enqueue: track, upcoming: [track], fillerUri: 'dj-1', + enabled: true, + leaseHeld: true, + enqueue: track, + upcoming: [track], + fillerUri: 'dj-1', }); - const { result } = renderHook(() => - useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue }), - ); + const { result } = renderHook(() => useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue })); await waitFor(() => expect(onEnqueue).toHaveBeenCalledWith(track)); await waitFor(() => expect(result.current.fillerUri).toBe('dj-1')); @@ -104,11 +120,15 @@ describe('useAutoplay', () => { it('still previews before a group has been picked', async () => { vi.mocked(window.sonos.fetchDjSet).mockResolvedValue({ - tracks: [track], artists: [], listeners: [], artistsConsidered: 1, inferredListeners: true, + tracks: [track], + artists: [], + listeners: [], + artistsConsidered: 1, + inferredListeners: true, }); const { result } = renderHook(() => - useAutoplay({ groupId: null, items: [], nowPlayingUri: null, onEnqueue: vi.fn() }), + useAutoplay({ groupId: null, items: [], nowPlayingUri: null, onEnqueue: vi.fn() }) ); // No group means no coordination — but the preview is still worth showing. @@ -119,32 +139,38 @@ describe('useAutoplay', () => { /** Each Sonos group is its own queue, with its own session and lease. */ it('coordinates per group', async () => { vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ - enabled: true, leaseHeld: true, enqueue: null, upcoming: [track], fillerUri: 'dj-1', + enabled: true, + leaseHeld: true, + enqueue: null, + upcoming: [track], + fillerUri: 'dj-1', }); const { rerender } = renderHook( ({ groupId }) => useAutoplay({ groupId, items: [], nowPlayingUri: null, onEnqueue: vi.fn() }), - { initialProps: { groupId: 'kitchen' as string | null } }, + { initialProps: { groupId: 'kitchen' as string | null } } ); await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); expect(vi.mocked(window.sonos.djAutoplay).mock.calls[0][0].groupId).toBe('kitchen'); rerender({ groupId: 'studio' }); await waitFor(() => - expect( - vi.mocked(window.sonos.djAutoplay).mock.calls.some((c) => c[0].groupId === 'studio'), - ).toBe(true), + expect(vi.mocked(window.sonos.djAutoplay).mock.calls.some((c) => c[0].groupId === 'studio')).toBe(true) ); }); it('drops the old room’s filler the moment the group changes', async () => { vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ - enabled: true, leaseHeld: false, enqueue: null, upcoming: [track], fillerUri: 'dj-1', + enabled: true, + leaseHeld: false, + enqueue: null, + upcoming: [track], + fillerUri: 'dj-1', }); const { result, rerender } = renderHook( ({ groupId }) => useAutoplay({ groupId, items: [], nowPlayingUri: null, onEnqueue: vi.fn() }), - { initialProps: { groupId: 'kitchen' as string | null } }, + { initialProps: { groupId: 'kitchen' as string | null } } ); await waitFor(() => expect(result.current.fillerUri).toBe('dj-1')); @@ -158,7 +184,11 @@ describe('useAutoplay', () => { it('reuses one client id across mounts so the lease does not bounce', async () => { vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ - enabled: false, leaseHeld: false, enqueue: null, upcoming: [], fillerUri: null, + enabled: false, + leaseHeld: false, + enqueue: null, + upcoming: [], + fillerUri: null, }); const opts = { groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue: vi.fn() }; @@ -176,13 +206,15 @@ describe('useAutoplay', () => { it('survives the coordinator being unreachable', async () => { vi.mocked(window.sonos.djAutoplay).mockRejectedValue(new Error('offline')); vi.mocked(window.sonos.fetchDjSet).mockResolvedValue({ - tracks: [], artists: [], listeners: [], artistsConsidered: 0, inferredListeners: true, + tracks: [], + artists: [], + listeners: [], + artistsConsidered: 0, + inferredListeners: true, }); const onEnqueue = vi.fn(); - const { result } = renderHook(() => - useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue }), - ); + const { result } = renderHook(() => useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue })); await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); expect(result.current.upcoming).toEqual([]); @@ -202,7 +234,7 @@ describe('useAutoplay', () => { }); const { result } = renderHook(() => - useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue: vi.fn() }), + useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue: vi.fn() }) ); await waitFor(() => expect(result.current.upcoming).toHaveLength(1)); @@ -211,7 +243,11 @@ describe('useAutoplay', () => { it('fills the preview when the coordinator answers with nothing', async () => { vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ - enabled: false, leaseHeld: false, enqueue: null, upcoming: [], fillerUri: null, + enabled: false, + leaseHeld: false, + enqueue: null, + upcoming: [], + fillerUri: null, }); vi.mocked(window.sonos.fetchDjSet).mockResolvedValue({ tracks: [track], @@ -222,7 +258,7 @@ describe('useAutoplay', () => { }); const { result } = renderHook(() => - useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue: vi.fn() }), + useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue: vi.fn() }) ); await waitFor(() => expect(result.current.upcoming).toHaveLength(1)); @@ -230,13 +266,15 @@ describe('useAutoplay', () => { it('never lets the stand-in overwrite what is really parked', async () => { vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ - enabled: true, leaseHeld: false, enqueue: null, + enabled: true, + leaseHeld: false, + enqueue: null, upcoming: [{ ...track, uri: 'real', trackName: 'Actually Parked' }], fillerUri: 'real', }); const { result } = renderHook(() => - useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue: vi.fn() }), + useAutoplay({ groupId: 'g1', items: [], nowPlayingUri: null, onEnqueue: vi.fn() }) ); await waitFor(() => expect(result.current.upcoming[0].trackName).toBe('Actually Parked')); @@ -250,7 +288,11 @@ describe('useAutoplay', () => { */ it('reports the playhead position, converted to a zero-based index', async () => { vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ - enabled: true, leaseHeld: false, enqueue: null, upcoming: [track], fillerUri: null, + enabled: true, + leaseHeld: false, + enqueue: null, + upcoming: [track], + fillerUri: null, }); renderHook(() => @@ -260,7 +302,7 @@ describe('useAutoplay', () => { nowPlayingUri: 'b', nowPlayingQueueItemId: '2', onEnqueue: vi.fn(), - }), + }) ); await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); @@ -269,7 +311,11 @@ describe('useAutoplay', () => { it('sends no index when the player does not report one', async () => { vi.mocked(window.sonos.djAutoplay).mockResolvedValue({ - enabled: true, leaseHeld: false, enqueue: null, upcoming: [track], fillerUri: null, + enabled: true, + leaseHeld: false, + enqueue: null, + upcoming: [track], + fillerUri: null, }); renderHook(() => @@ -279,7 +325,7 @@ describe('useAutoplay', () => { nowPlayingUri: 'a', nowPlayingQueueItemId: null, onEnqueue: vi.fn(), - }), + }) ); await waitFor(() => expect(window.sonos.djAutoplay).toHaveBeenCalled()); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useContainerBrowse.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useContainerBrowse.test.ts index e03711e..1f5726c 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useContainerBrowse.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useContainerBrowse.test.ts @@ -35,23 +35,15 @@ describe('useContainerBrowse', () => { }; mockFetch.mockResolvedValueOnce({ data: mockData }); - const { result } = renderHook( - () => useContainerBrowse('cont-1', 'svc', 'acc'), - { wrapper } - ); + const { result } = renderHook(() => useContainerBrowse('cont-1', 'svc', 'acc'), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.data).toBeInstanceOf(Array); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ operationId: 'browseContainer' }) - ); + expect(mockFetch).toHaveBeenCalledWith(expect.objectContaining({ operationId: 'browseContainer' })); }); it('returns error when response has error field', async () => { mockFetch.mockResolvedValueOnce({ data: null, error: 'Not found' }); - const { result } = renderHook( - () => useContainerBrowse('cont-1', 'svc', 'acc'), - { wrapper } - ); + const { result } = renderHook(() => useContainerBrowse('cont-1', 'svc', 'acc'), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.error).toBeTruthy(); }); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useDjSet.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useDjSet.test.ts index db85a80..fe9eb3f 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useDjSet.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useDjSet.test.ts @@ -37,9 +37,7 @@ describe('djSetQueryOptions', () => { }); it('survives a null response', async () => { - vi.mocked(window.sonos.fetchDjSet).mockResolvedValueOnce( - null as unknown as DjSetResponse, - ); + vi.mocked(window.sonos.fetchDjSet).mockResolvedValueOnce(null as unknown as DjSetResponse); const result = await djSetQueryOptions().queryFn(); expect(result.listeners).toEqual([]); expect(result.tracks).toEqual([]); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useDominantColor.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useDominantColor.test.ts index fd0d483..86d3b97 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useDominantColor.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useDominantColor.test.ts @@ -60,11 +60,12 @@ describe('useDominantColor', () => { }); it('resets to null when src becomes null', () => { - const { result, rerender } = renderHook( - ({ src }: { src: string | null }) => useDominantColor(src), - { initialProps: { src: 'http://example.com/art.jpg' } } - ); - act(() => { imageOnLoad?.(); }); + const { result, rerender } = renderHook(({ src }: { src: string | null }) => useDominantColor(src), { + initialProps: { src: 'http://example.com/art.jpg' }, + }); + act(() => { + imageOnLoad?.(); + }); expect(result.current).not.toBeNull(); rerender({ src: null as unknown as string }); @@ -72,9 +73,13 @@ describe('useDominantColor', () => { }); it('returns null when canvas context is unavailable', () => { - mockGetContext.mockReturnValueOnce(null as unknown as { drawImage: typeof mockDrawImage; getImageData: typeof mockGetImageData }); + mockGetContext.mockReturnValueOnce( + null as unknown as { drawImage: typeof mockDrawImage; getImageData: typeof mockGetImageData } + ); const { result } = renderHook(() => useDominantColor('http://example.com/art.jpg')); - act(() => { imageOnLoad?.(); }); + act(() => { + imageOnLoad?.(); + }); expect(result.current).toBeNull(); }); }); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useGroups.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useGroups.test.ts index 80b2059..2e9ea91 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useGroups.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useGroups.test.ts @@ -37,12 +37,17 @@ describe('useGroups', () => { it('updates when WS fires group data', () => { let captured: ((groups: unknown[]) => void) | undefined; - mockOnWsGroups.mockImplementation((cb: (groups: unknown[]) => void) => { captured = cb; return () => {}; }); + mockOnWsGroups.mockImplementation((cb: (groups: unknown[]) => void) => { + captured = cb; + return () => {}; + }); const { result } = renderHook(() => useGroups()); expect(result.current).toEqual([]); - act(() => { captured?.([rawGroup]); }); + act(() => { + captured?.([rawGroup]); + }); expect(result.current).toEqual([normalizedGroup]); }); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useImage.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useImage.test.ts index 965d541..0d6aea4 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useImage.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useImage.test.ts @@ -6,12 +6,16 @@ let getCached: ReturnType; let fetchImage: ReturnType; vi.mock('../../lib/imageCache', () => ({ - get getCached() { return getCached; }, - get fetchImage() { return fetchImage; }, + get getCached() { + return getCached; + }, + get fetchImage() { + return fetchImage; + }, })); beforeEach(() => { - getCached = vi.fn().mockReturnValue(null); + getCached = vi.fn().mockReturnValue(null); fetchImage = vi.fn().mockReturnValue(new Promise(() => {})); // pending by default }); @@ -42,7 +46,7 @@ describe('useImage', () => { }); it('calls fetchImage and updates state when not cached', async () => { - getCached = vi.fn().mockReturnValue(null); + getCached = vi.fn().mockReturnValue(null); fetchImage = vi.fn().mockResolvedValue('blob:fetched'); const useImage = await importUseImage(); const { result } = renderHook(() => useImage('https://example.com/art.jpg')); @@ -52,13 +56,12 @@ describe('useImage', () => { }); it('clears src when url changes to null', async () => { - getCached = vi.fn().mockReturnValue(null); + getCached = vi.fn().mockReturnValue(null); fetchImage = vi.fn().mockResolvedValue('blob:old'); const useImage = await importUseImage(); - const { result, rerender } = renderHook( - ({ url }) => useImage(url), - { initialProps: { url: 'https://example.com/art.jpg' as string | null } } - ); + const { result, rerender } = renderHook(({ url }) => useImage(url), { + initialProps: { url: 'https://example.com/art.jpg' as string | null }, + }); await waitFor(() => expect(result.current).toBe('blob:old')); rerender({ url: null }); expect(result.current).toBeNull(); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useNowPlaying.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useNowPlaying.test.ts index f4a2b89..130bf1c 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useNowPlaying.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useNowPlaying.test.ts @@ -73,19 +73,13 @@ describe('useNowPlaying', () => { }); it('splits timeLabel into elapsedLabel and durationLabel', () => { - const { result } = renderHook( - () => useNowPlaying(makePlayback({ timeLabel: '2:15 / 5:30' })), - { wrapper } - ); + const { result } = renderHook(() => useNowPlaying(makePlayback({ timeLabel: '2:15 / 5:30' })), { wrapper }); expect(result.current.elapsedLabel).toBe('2:15'); expect(result.current.durationLabel).toBe('5:30'); }); it('returns empty strings when timeLabel is empty', () => { - const { result } = renderHook( - () => useNowPlaying(makePlayback({ timeLabel: '' })), - { wrapper } - ); + const { result } = renderHook(() => useNowPlaying(makePlayback({ timeLabel: '' })), { wrapper }); expect(result.current.elapsedLabel).toBe(''); expect(result.current.durationLabel).toBe(''); }); @@ -113,19 +107,13 @@ describe('useNowPlaying', () => { accountId: 'acc', }, }); - const { result } = renderHook( - () => useNowPlaying(makePlayback()), - { wrapper } - ); + const { result } = renderHook(() => useNowPlaying(makePlayback()), { wrapper }); expect(result.current.albumItem).toBeTruthy(); expect(result.current.albumItem?.title).toBe('Great Album'); }); it('returns null albumItem when no albumId', () => { - const { result } = renderHook( - () => useNowPlaying(makePlayback({ currentAlbumId: null })), - { wrapper } - ); + const { result } = renderHook(() => useNowPlaying(makePlayback({ currentAlbumId: null })), { wrapper }); expect(result.current.albumItem).toBeNull(); }); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useOfficePresence.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useOfficePresence.test.ts index 445e2f8..38ee662 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useOfficePresence.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useOfficePresence.test.ts @@ -39,9 +39,7 @@ describe('officePresenceQueryOptions', () => { }); it('survives a malformed response without throwing', async () => { - vi.mocked(window.sonos.fetchOfficePresence).mockResolvedValueOnce( - {} as unknown as OfficePresence, - ); + vi.mocked(window.sonos.fetchOfficePresence).mockResolvedValueOnce({} as unknown as OfficePresence); const result = await officePresenceQueryOptions().queryFn(); expect(result.inOffice).toEqual([]); expect(result.detail).toEqual([]); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useOpenItem.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useOpenItem.test.ts index d72c0ce..3fc50eb 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useOpenItem.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useOpenItem.test.ts @@ -55,9 +55,6 @@ describe('useOpenItem', () => { const open = setup(); const item: SonosItem = { type: 'ITEM_ALBUM' }; open(item); - expect(mockNavigate).toHaveBeenCalledWith( - '/album/_', - expect.anything() - ); + expect(mockNavigate).toHaveBeenCalledWith('/album/_', expect.anything()); }); }); diff --git a/apps/desktop/renderer/src/hooks/__tests__/usePlayback.test.ts b/apps/desktop/renderer/src/hooks/__tests__/usePlayback.test.ts index 8a5f88a..5787e25 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/usePlayback.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/usePlayback.test.ts @@ -5,11 +5,11 @@ import type { PlaybackPayload } from '../../types/sonos'; // ── WS callback capture ─────────────────────────────────────────────────────── -let wsHandler: ((header: unknown, payload: unknown) => void) | null = null; +let wsHandler: ((header: unknown, payload: unknown) => void) | null = null; let readyHandler: (() => void) | null = null; beforeEach(() => { - wsHandler = null; + wsHandler = null; readyHandler = null; vi.clearAllMocks(); @@ -54,12 +54,16 @@ function makePayload(overrides: Partial = {}): PlaybackPayload /** Fire a playbackExtended WS message for the given group. */ function firePlayback(payload: PlaybackPayload, groupId = 'g1') { - act(() => { wsHandler!({ namespace: 'playbackExtended', groupId }, payload); }); + act(() => { + wsHandler!({ namespace: 'playbackExtended', groupId }, payload); + }); } /** Fire a groupVolume WS message. */ function fireVolume(volume: number, groupId = 'g1') { - act(() => { wsHandler!({ namespace: 'groupVolume', groupId }, { volume }); }); + act(() => { + wsHandler!({ namespace: 'groupVolume', groupId }, { volume }); + }); } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -82,7 +86,9 @@ describe('usePlayback — initial state', () => { describe('usePlayback — onWsReady', () => { it('sets isVisible true when the WS session is ready', () => { const { result } = renderHook(() => usePlayback('g1')); - act(() => { readyHandler!(); }); + act(() => { + readyHandler!(); + }); expect(result.current.playback.isVisible).toBe(true); }); }); @@ -97,25 +103,31 @@ describe('usePlayback — track name and artist', () => { it('falls back to track.title when name is absent', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - metadata: { currentItem: { track: { title: 'Via Title', artist: 'Someone', durationMillis: 0 } } }, - })); + firePlayback( + makePayload({ + metadata: { currentItem: { track: { title: 'Via Title', artist: 'Someone', durationMillis: 0 } } }, + }) + ); expect(result.current.playback.trackName).toBe('Via Title'); }); it('handles artist as a plain string', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - metadata: { currentItem: { track: { name: 'T', artist: 'String Artist', durationMillis: 0 } } }, - })); + firePlayback( + makePayload({ + metadata: { currentItem: { track: { name: 'T', artist: 'String Artist', durationMillis: 0 } } }, + }) + ); expect(result.current.playback.artistName).toBe('String Artist'); }); it('returns empty artistName when artist is absent', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - metadata: { currentItem: { track: { name: 'T', durationMillis: 0 } } }, - })); + firePlayback( + makePayload({ + metadata: { currentItem: { track: { name: 'T', durationMillis: 0 } } }, + }) + ); expect(result.current.playback.artistName).toBe(''); }); }); @@ -123,45 +135,61 @@ describe('usePlayback — track name and artist', () => { describe('usePlayback — progress and timing', () => { it('computes progressPct as posMs / durMs * 100', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - playback: { playbackState: 'PLAYING', positionMillis: 60000, queueId: 'q1', queueVersion: 'v1', playModes: {} }, - metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 120000 } } }, - })); + firePlayback( + makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 60000, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 120000 } } }, + }) + ); expect(result.current.playback.progressPct).toBeCloseTo(50); }); it('clamps progressPct to 100 when posMs > durMs', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - playback: { playbackState: 'PLAYING', positionMillis: 999999, queueId: 'q1', queueVersion: 'v1', playModes: {} }, - metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 180000 } } }, - })); + firePlayback( + makePayload({ + playback: { + playbackState: 'PLAYING', + positionMillis: 999999, + queueId: 'q1', + queueVersion: 'v1', + playModes: {}, + }, + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 180000 } } }, + }) + ); expect(result.current.playback.progressPct).toBe(100); }); it('sets progressPct to 0 when durMs is 0', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - playback: { playbackState: 'PLAYING', positionMillis: 10000, queueId: 'q1', queueVersion: 'v1', playModes: {} }, - metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0 } } }, - })); + firePlayback( + makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 10000, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0 } } }, + }) + ); expect(result.current.playback.progressPct).toBe(0); }); it('builds a timeLabel when duration is known', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - playback: { playbackState: 'PLAYING', positionMillis: 65000, queueId: 'q1', queueVersion: 'v1', playModes: {} }, - metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 183000 } } }, - })); + firePlayback( + makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 65000, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 183000 } } }, + }) + ); expect(result.current.playback.timeLabel).toBe('1:05 / 3:03'); }); it('sets empty timeLabel when duration is 0', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0 } } }, - })); + firePlayback( + makePayload({ + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0 } } }, + }) + ); expect(result.current.playback.timeLabel).toBe(''); }); }); @@ -169,18 +197,34 @@ describe('usePlayback — progress and timing', () => { describe('usePlayback — playback state', () => { it('sets isPlaying true and stateIcon ▶ when PLAYING', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - playback: { playbackState: 'PLAYBACK_STATE_PLAYING', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: {} }, - })); + firePlayback( + makePayload({ + playback: { + playbackState: 'PLAYBACK_STATE_PLAYING', + positionMillis: 0, + queueId: 'q1', + queueVersion: 'v1', + playModes: {}, + }, + }) + ); expect(result.current.playback.isPlaying).toBe(true); expect(result.current.playback.stateIcon).toBe('▶'); }); it('sets isPlaying false and stateIcon ⏸ when PAUSED', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - playback: { playbackState: 'PLAYBACK_STATE_PAUSED', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: {} }, - })); + firePlayback( + makePayload({ + playback: { + playbackState: 'PLAYBACK_STATE_PAUSED', + positionMillis: 0, + queueId: 'q1', + queueVersion: 'v1', + playModes: {}, + }, + }) + ); expect(result.current.playback.isPlaying).toBe(false); expect(result.current.playback.stateIcon).toBe('⏸'); }); @@ -188,18 +232,22 @@ describe('usePlayback — playback state', () => { it('keeps previous volume on non-NO_GROUPS payload', () => { const { result } = renderHook(() => usePlayback('g1')); fireVolume(72); - firePlayback(makePayload({ - playback: { playbackState: 'PLAYING', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: {} }, - })); + firePlayback( + makePayload({ + playback: { playbackState: 'PLAYING', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + }) + ); expect(result.current.playback.volume).toBe(72); }); it('zeros volume when playbackState is NO_GROUPS', () => { const { result } = renderHook(() => usePlayback('g1')); fireVolume(80); - firePlayback(makePayload({ - playback: { playbackState: 'NO_GROUPS', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: {} }, - })); + firePlayback( + makePayload({ + playback: { playbackState: 'NO_GROUPS', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: {} }, + }) + ); expect(result.current.playback.volume).toBe(0); }); }); @@ -207,33 +255,65 @@ describe('usePlayback — playback state', () => { describe('usePlayback — play modes', () => { it('sets shuffle true when playModes.shuffle is true', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - playback: { playbackState: 'PLAYING', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: { shuffle: true } }, - })); + firePlayback( + makePayload({ + playback: { + playbackState: 'PLAYING', + positionMillis: 0, + queueId: 'q1', + queueVersion: 'v1', + playModes: { shuffle: true }, + }, + }) + ); expect(result.current.playback.shuffle).toBe(true); }); it('sets repeat "one" when repeatOne is true', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - playback: { playbackState: 'PLAYING', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: { repeatOne: true } }, - })); + firePlayback( + makePayload({ + playback: { + playbackState: 'PLAYING', + positionMillis: 0, + queueId: 'q1', + queueVersion: 'v1', + playModes: { repeatOne: true }, + }, + }) + ); expect(result.current.playback.repeat).toBe('one'); }); it('sets repeat "all" when repeat is true and repeatOne is false', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - playback: { playbackState: 'PLAYING', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: { repeat: true, repeatOne: false } }, - })); + firePlayback( + makePayload({ + playback: { + playbackState: 'PLAYING', + positionMillis: 0, + queueId: 'q1', + queueVersion: 'v1', + playModes: { repeat: true, repeatOne: false }, + }, + }) + ); expect(result.current.playback.repeat).toBe('all'); }); it('sets repeat "none" when both repeat flags are false', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - playback: { playbackState: 'PLAYING', positionMillis: 0, queueId: 'q1', queueVersion: 'v1', playModes: { repeat: false, repeatOne: false } }, - })); + firePlayback( + makePayload({ + playback: { + playbackState: 'PLAYING', + positionMillis: 0, + queueId: 'q1', + queueVersion: 'v1', + playModes: { repeat: false, repeatOne: false }, + }, + }) + ); expect(result.current.playback.repeat).toBe('none'); }); }); @@ -241,35 +321,50 @@ describe('usePlayback — play modes', () => { describe('usePlayback — art URL', () => { it('picks the first https:// image URL', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - metadata: { currentItem: { track: { - name: 'T', artist: 'A', durationMillis: 0, - images: [ - { url: 'http://insecure.example.com/img.jpg' }, - { url: 'https://secure.example.com/img.jpg' }, - ], - } } }, - })); + firePlayback( + makePayload({ + metadata: { + currentItem: { + track: { + name: 'T', + artist: 'A', + durationMillis: 0, + images: [{ url: 'http://insecure.example.com/img.jpg' }, { url: 'https://secure.example.com/img.jpg' }], + }, + }, + }, + }) + ); expect(result.current.playback.artUrl).toBe('https://secure.example.com/img.jpg'); }); it('falls back to imageUrl when images array has no https entry', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - metadata: { currentItem: { track: { - name: 'T', artist: 'A', durationMillis: 0, - images: [], - imageUrl: 'https://fallback.example.com/img.jpg', - } } }, - })); + firePlayback( + makePayload({ + metadata: { + currentItem: { + track: { + name: 'T', + artist: 'A', + durationMillis: 0, + images: [], + imageUrl: 'https://fallback.example.com/img.jpg', + }, + }, + }, + }) + ); expect(result.current.playback.artUrl).toBe('https://fallback.example.com/img.jpg'); }); it('sets artUrl null when no images are available', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0, images: [] } } }, - })); + firePlayback( + makePayload({ + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0, images: [] } } }, + }) + ); expect(result.current.playback.artUrl).toBeNull(); }); }); @@ -277,17 +372,21 @@ describe('usePlayback — art URL', () => { describe('usePlayback — explicit flag', () => { it('reads explicit from track.explicit', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0, explicit: true } } }, - })); + firePlayback( + makePayload({ + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0, explicit: true } } }, + }) + ); expect(result.current.playback.isExplicit).toBe(true); }); it('reads explicit from track.isExplicit (legacy field)', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0, isExplicit: true } as never } }, - })); + firePlayback( + makePayload({ + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 0, isExplicit: true } as never } }, + }) + ); expect(result.current.playback.isExplicit).toBe(true); }); }); @@ -327,19 +426,30 @@ describe('usePlayback — queue cursor refs', () => { describe('usePlayback — no track name (queue-cursor-only path)', () => { it('does not set trackName or isVisible when payload has no track name', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - metadata: { currentItem: { track: { durationMillis: 0 } } }, - })); + firePlayback( + makePayload({ + metadata: { currentItem: { track: { durationMillis: 0 } } }, + }) + ); expect(result.current.playback.trackName).toBe(''); expect(result.current.playback.isVisible).toBe(false); }); it('still updates queueId in state when track name is absent', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - playback: { playbackState: 'PLAYING', positionMillis: 0, queueId: 'q-new', queueVersion: 'v-new', itemId: 'qi2', playModes: {} }, - metadata: { currentItem: { track: { durationMillis: 0 } } }, - })); + firePlayback( + makePayload({ + playback: { + playbackState: 'PLAYING', + positionMillis: 0, + queueId: 'q-new', + queueVersion: 'v-new', + itemId: 'qi2', + playModes: {}, + }, + metadata: { currentItem: { track: { durationMillis: 0 } } }, + }) + ); expect(result.current.playback.queueId).toBe('q-new'); expect(result.current.playback.queueVersion).toBe('v-new'); expect(result.current.playback.queueItemId).toBe('qi2'); @@ -368,7 +478,9 @@ describe('usePlayback — group filtering', () => { firePlayback(g2Payload, 'g2'); // Switch to g2 — the cached payload should be applied - act(() => { result.current.applyGroupCache('g2'); }); + act(() => { + result.current.applyGroupCache('g2'); + }); expect(result.current.playback.trackName).toBe('G2 Track'); }); }); @@ -392,7 +504,9 @@ describe('usePlayback — groupVolume namespace', () => { describe('usePlayback — applyGroupCache', () => { it('shows "Nothing playing" when no cache entry exists for the group', () => { const { result } = renderHook(() => usePlayback('g1')); - act(() => { result.current.applyGroupCache('g2'); }); + act(() => { + result.current.applyGroupCache('g2'); + }); expect(result.current.playback.trackName).toBe('Nothing playing'); expect(result.current.playback.isVisible).toBe(true); }); @@ -400,11 +514,18 @@ describe('usePlayback — applyGroupCache', () => { it('applies cached payload when switching to a group with history', () => { const { result } = renderHook(() => usePlayback('g1')); // Cache a payload for g1 by firing a WS message - firePlayback(makePayload({ metadata: { currentItem: { track: { name: 'Cached Song', artist: 'X', durationMillis: 0 } } } }), 'g1'); + firePlayback( + makePayload({ metadata: { currentItem: { track: { name: 'Cached Song', artist: 'X', durationMillis: 0 } } } }), + 'g1' + ); // Switch away and back - act(() => { result.current.applyGroupCache('g2'); }); - act(() => { result.current.applyGroupCache('g1'); }); + act(() => { + result.current.applyGroupCache('g2'); + }); + act(() => { + result.current.applyGroupCache('g1'); + }); expect(result.current.playback.trackName).toBe('Cached Song'); }); @@ -413,7 +534,9 @@ describe('usePlayback — applyGroupCache', () => { firePlayback(makePayload()); // populates queueVersionRef = 'v1' expect(result.current.queueVersionRef.current).toBe('v1'); - act(() => { result.current.applyGroupCache('g2'); }); + act(() => { + result.current.applyGroupCache('g2'); + }); expect(result.current.queueVersionRef.current).toBeNull(); }); @@ -422,47 +545,80 @@ describe('usePlayback — applyGroupCache', () => { firePlayback(makePayload()); expect(result.current.queueIdRef.current).toBe('q1'); - act(() => { result.current.applyGroupCache('g2'); }); + act(() => { + result.current.applyGroupCache('g2'); + }); expect(result.current.queueIdRef.current).toBeNull(); }); it('filters out WS messages from the old group immediately after switch', () => { const { result } = renderHook(() => usePlayback('g1')); - act(() => { result.current.applyGroupCache('g2'); }); + act(() => { + result.current.applyGroupCache('g2'); + }); // A message still tagged g1 should now be ignored - firePlayback(makePayload({ metadata: { currentItem: { track: { name: 'Old Group Track', artist: 'X', durationMillis: 0 } } } }), 'g1'); + firePlayback( + makePayload({ + metadata: { currentItem: { track: { name: 'Old Group Track', artist: 'X', durationMillis: 0 } } }, + }), + 'g1' + ); expect(result.current.playback.trackName).toBe('Nothing playing'); }); }); describe('usePlayback — progress tick', () => { - beforeEach(() => { vi.useFakeTimers(); }); - afterEach(() => { vi.useRealTimers(); }); + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); it('advances progressPct over time while playing', () => { const { result } = renderHook(() => usePlayback('g1')); // Start at 50% (90s / 180s) - firePlayback(makePayload({ - playback: { playbackState: 'PLAYBACK_STATE_PLAYING', positionMillis: 90000, queueId: 'q1', queueVersion: 'v1', playModes: {} }, - metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 180000 } } }, - })); + firePlayback( + makePayload({ + playback: { + playbackState: 'PLAYBACK_STATE_PLAYING', + positionMillis: 90000, + queueId: 'q1', + queueVersion: 'v1', + playModes: {}, + }, + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 180000 } } }, + }) + ); const initialPct = result.current.playback.progressPct; - act(() => { vi.advanceTimersByTime(5000); }); + act(() => { + vi.advanceTimersByTime(5000); + }); expect(result.current.playback.progressPct).toBeGreaterThan(initialPct); }); it('does not tick when paused', () => { const { result } = renderHook(() => usePlayback('g1')); - firePlayback(makePayload({ - playback: { playbackState: 'PLAYBACK_STATE_PAUSED', positionMillis: 90000, queueId: 'q1', queueVersion: 'v1', playModes: {} }, - metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 180000 } } }, - })); + firePlayback( + makePayload({ + playback: { + playbackState: 'PLAYBACK_STATE_PAUSED', + positionMillis: 90000, + queueId: 'q1', + queueVersion: 'v1', + playModes: {}, + }, + metadata: { currentItem: { track: { name: 'T', artist: 'A', durationMillis: 180000 } } }, + }) + ); const initialPct = result.current.playback.progressPct; - act(() => { vi.advanceTimersByTime(5000); }); + act(() => { + vi.advanceTimersByTime(5000); + }); expect(result.current.playback.progressPct).toBe(initialPct); }); diff --git a/apps/desktop/renderer/src/hooks/__tests__/usePlaylistBrowse.test.ts b/apps/desktop/renderer/src/hooks/__tests__/usePlaylistBrowse.test.ts index 8a0e664..47288c5 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/usePlaylistBrowse.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/usePlaylistBrowse.test.ts @@ -76,7 +76,16 @@ describe('parsePlaylistTracks', () => { it('collects artist names into artists array', () => { const result = parsePlaylistTracks({ - tracks: { items: [makeTrack('Song', { artists: [{ name: 'Alpha', id: 'id-1' }, { name: 'Beta', id: 'id-2' }] })] }, + tracks: { + items: [ + makeTrack('Song', { + artists: [ + { name: 'Alpha', id: 'id-1' }, + { name: 'Beta', id: 'id-2' }, + ], + }), + ], + }, }); expect(result[0].artists).toEqual(['Alpha', 'Beta']); }); @@ -106,17 +115,20 @@ describe('parsePlaylistTracks', () => { // ─── usePlaylistBrowse hook ─────────────────────────────────────────────────── function wrapper({ children }: { children: React.ReactNode }) { - return createElement(QueryClientProvider, { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, children); + return createElement( + QueryClientProvider, + { client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) }, + children + ); } describe('usePlaylistBrowse', () => { - beforeEach(() => { mockApiPlaylist.mockReset(); }); + beforeEach(() => { + mockApiPlaylist.mockReset(); + }); it('is disabled when playlistId is undefined', () => { - const { result } = renderHook( - () => usePlaylistBrowse(undefined, 'svc', 'acc'), - { wrapper } - ); + const { result } = renderHook(() => usePlaylistBrowse(undefined, 'svc', 'acc'), { wrapper }); expect(result.current.isLoading).toBe(false); expect(result.current.data).toBeUndefined(); }); @@ -126,20 +138,14 @@ describe('usePlaylistBrowse', () => { error: null, data: { tracks: { items: [makeTrack('Fetched Song')] } }, }); - const { result } = renderHook( - () => usePlaylistBrowse('pl-1', 'svc', 'acc'), - { wrapper } - ); + const { result } = renderHook(() => usePlaylistBrowse('pl-1', 'svc', 'acc'), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.data?.[0]?.title).toBe('Fetched Song'); }); it('throws when api returns error', async () => { mockApiPlaylist.mockResolvedValue({ error: 'Not found', data: null }); - const { result } = renderHook( - () => usePlaylistBrowse('pl-1', 'svc', 'acc'), - { wrapper } - ); + const { result } = renderHook(() => usePlaylistBrowse('pl-1', 'svc', 'acc'), { wrapper }); await waitFor(() => expect(result.current.isError).toBe(true)); }); }); diff --git a/apps/desktop/renderer/src/hooks/__tests__/usePlaylists.test.ts b/apps/desktop/renderer/src/hooks/__tests__/usePlaylists.test.ts index 90ef984..3f37e82 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/usePlaylists.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/usePlaylists.test.ts @@ -10,8 +10,7 @@ vi.mock('../../components/common/Toast', () => ({ })); function makeWrapper(qc: QueryClient) { - return ({ children }: { children: React.ReactNode }) => - createElement(QueryClientProvider, { client: qc }, children); + return ({ children }: { children: React.ReactNode }) => createElement(QueryClientProvider, { client: qc }, children); } function makeQc() { @@ -68,10 +67,7 @@ describe('useFavourite', () => { it('returns isFavourited=false when playlist is not in cache', () => { const qc = makeQc(); - const { result } = renderHook( - () => useFavourite(DISPLAY_NAME, sampleTrack), - { wrapper: makeWrapper(qc) }, - ); + const { result } = renderHook(() => useFavourite(DISPLAY_NAME, sampleTrack), { wrapper: makeWrapper(qc) }); expect(result.current.isFavourited).toBe(false); }); @@ -80,10 +76,7 @@ describe('useFavourite', () => { const favId = favouritesId(DISPLAY_NAME); qc.setQueryData(['playlist', favId], makeFavPlaylist([makePlaylistTrack()])); - const { result } = renderHook( - () => useFavourite(DISPLAY_NAME, sampleTrack), - { wrapper: makeWrapper(qc) }, - ); + const { result } = renderHook(() => useFavourite(DISPLAY_NAME, sampleTrack), { wrapper: makeWrapper(qc) }); expect(result.current.isFavourited).toBe(true); }); @@ -93,16 +86,13 @@ describe('useFavourite', () => { qc.setQueryData(['playlist', favId], makeFavPlaylist()); vi.mocked(window.sonos.addTrackToPlaylist).mockResolvedValueOnce({} as PlaylistDoc); - const { result } = renderHook( - () => useFavourite(DISPLAY_NAME, sampleTrack), - { wrapper: makeWrapper(qc) }, - ); + const { result } = renderHook(() => useFavourite(DISPLAY_NAME, sampleTrack), { wrapper: makeWrapper(qc) }); await act(() => result.current.toggle()); expect(vi.mocked(window.sonos.addTrackToPlaylist)).toHaveBeenCalledWith( favId, - expect.objectContaining({ uri: TRACK_URI, addedBy: DISPLAY_NAME }), + expect.objectContaining({ uri: TRACK_URI, addedBy: DISPLAY_NAME }) ); const updated = qc.getQueryData(['playlist', favId]); expect(updated?.tracks).toHaveLength(1); @@ -115,10 +105,7 @@ describe('useFavourite', () => { qc.setQueryData(['playlist', favId], makeFavPlaylist()); vi.mocked(window.sonos.addTrackToPlaylist).mockRejectedValueOnce(new Error('network')); - const { result } = renderHook( - () => useFavourite(DISPLAY_NAME, sampleTrack), - { wrapper: makeWrapper(qc) }, - ); + const { result } = renderHook(() => useFavourite(DISPLAY_NAME, sampleTrack), { wrapper: makeWrapper(qc) }); await act(() => result.current.toggle()); @@ -133,10 +120,7 @@ describe('useFavourite', () => { qc.setQueryData(['playlist', favId], makeFavPlaylist([makePlaylistTrack()])); vi.mocked(window.sonos.removeTrackFromPlaylist).mockResolvedValueOnce({} as PlaylistDoc); - const { result } = renderHook( - () => useFavourite(DISPLAY_NAME, sampleTrack), - { wrapper: makeWrapper(qc) }, - ); + const { result } = renderHook(() => useFavourite(DISPLAY_NAME, sampleTrack), { wrapper: makeWrapper(qc) }); expect(result.current.isFavourited).toBe(true); await act(() => result.current.toggle()); @@ -152,10 +136,7 @@ describe('useFavourite', () => { qc.setQueryData(['playlist', favId], makeFavPlaylist([makePlaylistTrack()])); vi.mocked(window.sonos.removeTrackFromPlaylist).mockRejectedValueOnce(new Error('network')); - const { result } = renderHook( - () => useFavourite(DISPLAY_NAME, sampleTrack), - { wrapper: makeWrapper(qc) }, - ); + const { result } = renderHook(() => useFavourite(DISPLAY_NAME, sampleTrack), { wrapper: makeWrapper(qc) }); expect(result.current.isFavourited).toBe(true); await act(() => result.current.toggle()); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useQueue.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useQueue.test.ts index 7b957c3..8dbc7d9 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useQueue.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useQueue.test.ts @@ -29,9 +29,7 @@ describe('useQueue — initial load', () => { mockFetch.mockResolvedValue(queueResponse()); renderHook(() => useQueue(true, 'g1', 'q1')); await waitFor(() => - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ operationId: 'getQueueResources' }), - ), + expect(mockFetch).toHaveBeenCalledWith(expect.objectContaining({ operationId: 'getQueueResources' })) ); }); @@ -149,9 +147,7 @@ describe('useQueue — reload', () => { }); it('does not fire onEtag on reload when list response has no etag', async () => { - mockFetch - .mockResolvedValueOnce(queueResponse([], 'etag-initial')) - .mockResolvedValueOnce(queueResponse([])); + mockFetch.mockResolvedValueOnce(queueResponse([], 'etag-initial')).mockResolvedValueOnce(queueResponse([])); const onEtag = vi.fn(); const { result } = renderHook(() => useQueue(true, 'g1', 'q1', onEtag)); await waitFor(() => expect(onEtag).toHaveBeenCalledWith('etag-initial')); @@ -173,9 +169,7 @@ describe('useQueue — reload error handling', () => { .mockResolvedValueOnce(queueResponse([{ name: 'Old Song' }], 'etag-v1')) .mockResolvedValueOnce({ data: null, error: '500 Internal Server Error' }); const onReloadError = vi.fn(); - const { result } = renderHook(() => - useQueue(true, 'g1', 'q1', undefined, onReloadError), - ); + const { result } = renderHook(() => useQueue(true, 'g1', 'q1', undefined, onReloadError)); await waitFor(() => expect(result.current.items[0]?.track.title).toBe('Old Song')); await act(async () => { @@ -190,9 +184,7 @@ describe('useQueue — reload error handling', () => { it('still surfaces the error in state when the very first load fails (no items yet)', async () => { mockFetch.mockResolvedValueOnce({ data: null, error: '500 Internal Server Error' }); const onReloadError = vi.fn(); - const { result } = renderHook(() => - useQueue(true, 'g1', 'q1', undefined, onReloadError), - ); + const { result } = renderHook(() => useQueue(true, 'g1', 'q1', undefined, onReloadError)); await waitFor(() => expect(result.current.error).toBe('500 Internal Server Error')); expect(result.current.items).toHaveLength(0); expect(onReloadError).not.toHaveBeenCalled(); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useQueueTrack.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useQueueTrack.test.ts index fddfeb6..412fb95 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useQueueTrack.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useQueueTrack.test.ts @@ -39,39 +39,35 @@ beforeEach(() => { describe('useQueueTrack', () => { it('returns artist and artUrl from item when no track details', () => { - const { result } = renderHook( - () => useQueueTrack(baseItem, null), - { wrapper } - ); + const { result } = renderHook(() => useQueueTrack(baseItem, null), { wrapper }); expect(result.current.artist).toBeDefined(); expect(result.current.explicit).toBe(false); }); it('prefers track details over raw item values', () => { mockTrackDetails.mockReturnValue({ - data: { artist: 'Enriched Artist', artUrl: 'http://img.com/art.jpg', albumName: 'Abbey Road', albumId: null, serviceId: null, accountId: null, artistId: null }, + data: { + artist: 'Enriched Artist', + artUrl: 'http://img.com/art.jpg', + albumName: 'Abbey Road', + albumId: null, + serviceId: null, + accountId: null, + artistId: null, + }, }); - const { result } = renderHook( - () => useQueueTrack(baseItem, null), - { wrapper } - ); + const { result } = renderHook(() => useQueueTrack(baseItem, null), { wrapper }); expect(result.current.artist).toBe('Enriched Artist'); expect(result.current.artUrl).toBe('http://img.com/art.jpg'); }); it('isPlaying is true when currentObjectId matches item objectId', () => { - const { result } = renderHook( - () => useQueueTrack(baseItem, 'obj-1'), - { wrapper } - ); + const { result } = renderHook(() => useQueueTrack(baseItem, 'obj-1'), { wrapper }); expect(result.current.isPlaying).toBe(true); }); it('isPlaying is false when currentObjectId does not match', () => { - const { result } = renderHook( - () => useQueueTrack(baseItem, 'other-obj'), - { wrapper } - ); + const { result } = renderHook(() => useQueueTrack(baseItem, 'other-obj'), { wrapper }); expect(result.current.isPlaying).toBe(false); }); @@ -87,22 +83,24 @@ describe('useQueueTrack', () => { artistId: null, }, }); - const { result } = renderHook( - () => useQueueTrack(baseItem, null), - { wrapper } - ); + const { result } = renderHook(() => useQueueTrack(baseItem, null), { wrapper }); expect(result.current.albumItem).not.toBeNull(); expect(result.current.albumItem?.title).toBe('Abbey Road'); }); it('returns null albumItem when albumId is missing', () => { mockTrackDetails.mockReturnValue({ - data: { artist: 'Artist', artUrl: null, albumName: null, albumId: null, serviceId: null, accountId: null, artistId: null }, + data: { + artist: 'Artist', + artUrl: null, + albumName: null, + albumId: null, + serviceId: null, + accountId: null, + artistId: null, + }, }); - const { result } = renderHook( - () => useQueueTrack(baseItem, null), - { wrapper } - ); + const { result } = renderHook(() => useQueueTrack(baseItem, null), { wrapper }); expect(result.current.albumItem).toBeNull(); }); @@ -118,19 +116,13 @@ describe('useQueueTrack', () => { artistId: 'artist-1', }, }); - const { result } = renderHook( - () => useQueueTrack(baseItem, null), - { wrapper } - ); + const { result } = renderHook(() => useQueueTrack(baseItem, null), { wrapper }); expect(result.current.artistItem).not.toBeNull(); expect(result.current.artistItem?.name).toBe('The Beatles'); }); it('returns null artistItem when artistId is missing', () => { - const { result } = renderHook( - () => useQueueTrack(baseItem, null), - { wrapper } - ); + const { result } = renderHook(() => useQueueTrack(baseItem, null), { wrapper }); expect(result.current.artistItem).toBeNull(); }); @@ -139,10 +131,7 @@ describe('useQueueTrack', () => { ...baseItem, track: { ...baseItem.track, isExplicit: true }, }; - const { result } = renderHook( - () => useQueueTrack(explicitItem, null), - { wrapper } - ); + const { result } = renderHook(() => useQueueTrack(explicitItem, null), { wrapper }); expect(result.current.explicit).toBe(true); }); }); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useStats.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useStats.test.ts index d845a80..f01ea07 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useStats.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useStats.test.ts @@ -29,7 +29,14 @@ describe('useStats', () => { }); it('passes userId when provided', async () => { - mockFetchStats.mockResolvedValue({ topUsers: [], topTracks: [], topArtists: [], topAlbums: [], totalEvents: 0, periodStart: 0 }); + mockFetchStats.mockResolvedValue({ + topUsers: [], + topTracks: [], + topArtists: [], + topAlbums: [], + totalEvents: 0, + periodStart: 0, + }); const { result } = renderHook(() => useStats('week', 'alice'), { wrapper }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(mockFetchStats).toHaveBeenCalledWith('week', 'alice'); diff --git a/apps/desktop/renderer/src/hooks/__tests__/useTrackDetails.test.ts b/apps/desktop/renderer/src/hooks/__tests__/useTrackDetails.test.ts index 8bee31c..8e586a7 100644 --- a/apps/desktop/renderer/src/hooks/__tests__/useTrackDetails.test.ts +++ b/apps/desktop/renderer/src/hooks/__tests__/useTrackDetails.test.ts @@ -133,9 +133,7 @@ describe('YT Music fallback', () => { }); it('returns null when both primary and YT fallback fail', async () => { - mockFetch - .mockResolvedValueOnce({ data: null, error: 'fail' }) - .mockResolvedValueOnce({ data: null, error: 'fail' }); + mockFetch.mockResolvedValueOnce({ data: null, error: 'fail' }).mockResolvedValueOnce({ data: null, error: 'fail' }); const result = await trackQueryOptions('t', 'gm', 'a').queryFn(); expect(result).toBeNull(); diff --git a/apps/desktop/renderer/src/hooks/useAlbumBrowse.ts b/apps/desktop/renderer/src/hooks/useAlbumBrowse.ts index 483b71c..aeb5422 100644 --- a/apps/desktop/renderer/src/hooks/useAlbumBrowse.ts +++ b/apps/desktop/renderer/src/hooks/useAlbumBrowse.ts @@ -1,8 +1,8 @@ -import { useQuery } from "@tanstack/react-query"; -import { api } from "../lib/sonosApi"; -import type { SonosItem, SonosItemId } from "../types/sonos"; -import { AlbumResponse } from "../types/AlbumResponse"; -import { decodeDefaults } from "../lib/itemHelpers"; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/sonosApi'; +import type { SonosItem, SonosItemId } from '../types/sonos'; +import { AlbumResponse } from '../types/AlbumResponse'; +import { decodeDefaults } from '../lib/itemHelpers'; function cleanArtistId(id: string): string { return id.replace(/^srn:content:audio:artist:/, '').replace(/#.*$/, ''); @@ -19,7 +19,7 @@ export interface AlbumTrack { artistObjects?: Array<{ name: string; objectId: string }>; /** Album/container name and id — decoded from track defaults, populated for playlist tracks. */ albumName: string | null; - albumId: string | null; + albumId: string | null; explicit: boolean; raw: SonosItem; } @@ -33,50 +33,52 @@ export interface AlbumData { artistItem: SonosItem | null; } -type RawTrack = NonNullable["items"][number]; +type RawTrack = NonNullable['items'][number]; function parseTrack(track: RawTrack): AlbumTrack { return { - title: track.title ?? "", - ordinal: track.ordinal ?? 0, + title: track.title ?? '', + ordinal: track.ordinal ?? 0, durationSeconds: Number(track.duration ?? 0), - artUrl: track.images?.tile1x1 ?? null, - id: (track.resource?.id ?? {}) as SonosItemId, - artists: track.artists?.map((a) => a.name) ?? [], - artistObjects: track.artists?.filter((a) => a.id).map((a) => ({ name: a.name, objectId: cleanArtistId(a.id as string) })), - albumName: null, - albumId: null, - explicit: track.isExplicit ?? false, - raw: track as unknown as SonosItem, + artUrl: track.images?.tile1x1 ?? null, + id: (track.resource?.id ?? {}) as SonosItemId, + artists: track.artists?.map((a) => a.name) ?? [], + artistObjects: track.artists + ?.filter((a) => a.id) + .map((a) => ({ name: a.name, objectId: cleanArtistId(a.id as string) })), + albumName: null, + albumId: null, + explicit: track.isExplicit ?? false, + raw: track as unknown as SonosItem, }; } function parseAlbum(data: AlbumResponse): AlbumData { - const title = data.title ?? ""; - const artist = data.subtitle ?? ""; + const title = data.title ?? ''; + const artist = data.subtitle ?? ''; const artUrl = data.images?.tile1x1 ?? null; // Decode album defaults to find the artist's objectId - const decoded = decodeDefaults(data.resource?.defaults); - const artistId = decoded?.["artistId"] as string | undefined; + const decoded = decodeDefaults(data.resource?.defaults); + const artistId = decoded?.['artistId'] as string | undefined; const serviceId = data.resource?.id?.serviceId; const accountId = data.resource?.id?.accountId; const artistItem: SonosItem | null = artistId && serviceId && accountId ? ({ - type: "ARTIST", - title: (decoded?.["artist"] as string | undefined) ?? artist, + type: 'ARTIST', + title: (decoded?.['artist'] as string | undefined) ?? artist, resource: { - type: "ARTIST", + type: 'ARTIST', id: { objectId: artistId, serviceId, accountId }, defaults: undefined, }, } as SonosItem) : null; - const rawTracks = data.tracks?.items ?? []; + const rawTracks = data.tracks?.items ?? []; const totalTracks = data.tracks?.total ?? rawTracks.length; - const tracks = rawTracks.map(parseTrack); + const tracks = rawTracks.map(parseTrack); return { title, artist, artUrl, tracks, totalTracks, artistItem }; } @@ -85,10 +87,10 @@ export function albumQueryOptions( albumId: string | undefined, serviceId: string | undefined, accountId: string | undefined, - defaults: string | undefined, + defaults: string | undefined ) { return { - queryKey: ["album", albumId] as const, + queryKey: ['album', albumId] as const, queryFn: async () => { const r = await api.browse.album(albumId!, { serviceId, accountId, defaults, muse2: true, count: 50 }); if (r.error) throw new Error(r.error); @@ -98,7 +100,14 @@ export function albumQueryOptions( let allTracks = [...parsed.tracks]; let offset = allTracks.length; while (offset < parsed.totalTracks) { - const page = await api.browse.album(albumId!, { serviceId, accountId, defaults, muse2: true, count: 50, offset }); + const page = await api.browse.album(albumId!, { + serviceId, + accountId, + defaults, + muse2: true, + count: 50, + offset, + }); if (page.error) break; const newItems = (page.data as AlbumResponse).tracks?.items ?? []; if (!newItems.length) break; @@ -117,7 +126,7 @@ export function useAlbumBrowse( albumId: string | undefined, serviceId: string | undefined, accountId: string | undefined, - defaults: string | undefined, + defaults: string | undefined ) { return useQuery({ ...albumQueryOptions(albumId, serviceId, accountId, defaults), diff --git a/apps/desktop/renderer/src/hooks/useArtistBrowse.ts b/apps/desktop/renderer/src/hooks/useArtistBrowse.ts index 0a2acae..ce0403d 100644 --- a/apps/desktop/renderer/src/hooks/useArtistBrowse.ts +++ b/apps/desktop/renderer/src/hooks/useArtistBrowse.ts @@ -16,7 +16,10 @@ export interface ArtistData { genius: GeniusArtistInfo | null; } -function parseArtist(data: ArtistResponse): { parsed: Omit; topSongsItem: SonosItem | null } { +function parseArtist(data: ArtistResponse): { + parsed: Omit; + topSongsItem: SonosItem | null; +} { const name = data.title ?? ''; const imageUrl = data.images?.tile1x1 ?? null; const allItems = (data.sections?.items?.[0]?.items ?? []) as unknown as SonosItem[]; @@ -36,7 +39,7 @@ export function artistQueryOptions( serviceId: string | undefined, accountId: string | undefined, defaults: string | undefined, - fallbackName?: string, + fallbackName?: string ) { return { queryKey: ['artist', artistId] as const, @@ -89,10 +92,7 @@ export function artistQueryOptions( return pr.error ? [] : parsePlaylistTracks(pr.data); }; - const [topSongs, genius] = await Promise.all([ - fetchTopSongs(), - window.sonos.geniusArtist(parsed.name), - ]); + const [topSongs, genius] = await Promise.all([fetchTopSongs(), window.sonos.geniusArtist(parsed.name)]); return { ...parsed, topSongs, genius }; }, @@ -106,7 +106,7 @@ export function useArtistBrowse( serviceId: string | undefined, accountId: string | undefined, defaults?: string, - fallbackName?: string, + fallbackName?: string ) { return useQuery({ ...artistQueryOptions(artistId, serviceId, accountId, defaults, fallbackName), @@ -123,7 +123,7 @@ export function useArtistImage( artistId: string | undefined, serviceId: string | undefined, accountId: string | undefined, - defaults?: string, + defaults?: string ) { return useQuery({ queryKey: ['artist-image', artistId] as const, diff --git a/apps/desktop/renderer/src/hooks/useAttribution.ts b/apps/desktop/renderer/src/hooks/useAttribution.ts index e33a57e070356747e261f5f5d0b93f927c654cd7..fd5c383fcf40347acd8d72957968ca2767d6599e 100644 GIT binary patch delta 24 fcmaDQ{y}_$83zlOf`Y { - const unsubReady = window.sonos.onAuthReady(() => setIsAuthed(true)); + const unsubReady = window.sonos.onAuthReady(() => setIsAuthed(true)); const unsubExpired = window.sonos.onAuthExpired(() => setIsAuthed(false)); - return () => { unsubReady(); unsubExpired(); }; + return () => { + unsubReady(); + unsubExpired(); + }; }, []); return isAuthed; diff --git a/apps/desktop/renderer/src/hooks/useAutoplay.ts b/apps/desktop/renderer/src/hooks/useAutoplay.ts index 89d42b8..2377ec7 100644 --- a/apps/desktop/renderer/src/hooks/useAutoplay.ts +++ b/apps/desktop/renderer/src/hooks/useAutoplay.ts @@ -98,13 +98,10 @@ export function useAutoplay({ // Resolve the playing track's name identity from the queue, so the // coordinator can place the playhead even when the id has been re-keyed. const playing = items.find((i) => i.track.id === nowPlayingUri); - nowPlayingTitleRef.current = playing - ? trackKey(playing.track.title, playing.track.artist) - : null; + nowPlayingTitleRef.current = playing ? trackKey(playing.track.title, playing.track.artist) : null; // Sonos reports a 1-based queue position; the coordinator works in indexes. const reported = Number(nowPlayingQueueItemId); - nowPlayingIndexRef.current = - nowPlayingQueueItemId && Number.isFinite(reported) ? reported - 1 : null; + nowPlayingIndexRef.current = nowPlayingQueueItemId && Number.isFinite(reported) ? reported - 1 : null; usersRef.current = users; onEnqueueRef.current = onEnqueue; }); @@ -212,7 +209,7 @@ export function useAutoplay({ pendingEnable.current = on; void poll(); }, - [poll], + [poll] ); return { ...state, setEnabled }; diff --git a/apps/desktop/renderer/src/hooks/useContainerBrowse.ts b/apps/desktop/renderer/src/hooks/useContainerBrowse.ts index 46dd983..2c86d79 100644 --- a/apps/desktop/renderer/src/hooks/useContainerBrowse.ts +++ b/apps/desktop/renderer/src/hooks/useContainerBrowse.ts @@ -5,9 +5,9 @@ import type { SonosItem } from '../types/sonos'; export function useContainerBrowse( containerId: string | undefined, - serviceId: string | undefined, - accountId: string | undefined, - defaults?: string, + serviceId: string | undefined, + accountId: string | undefined, + defaults?: string ) { return useQuery({ queryKey: ['container', containerId] as const, @@ -18,6 +18,6 @@ export function useContainerBrowse( }, enabled: !!(containerId && serviceId && accountId), staleTime: 5 * 60 * 1000, - gcTime: 60 * 60 * 1000, + gcTime: 60 * 60 * 1000, }); } diff --git a/apps/desktop/renderer/src/hooks/useDominantColor.ts b/apps/desktop/renderer/src/hooks/useDominantColor.ts index 16c7931..76d387c 100644 --- a/apps/desktop/renderer/src/hooks/useDominantColor.ts +++ b/apps/desktop/renderer/src/hooks/useDominantColor.ts @@ -4,13 +4,17 @@ export function useDominantColor(src: string | null, { setGlobal = false } = {}) const [color, setColor] = useState(null); useEffect(() => { - if (!src) { setColor(null); return; } + if (!src) { + setColor(null); + return; + } setColor(null); // clear stale color immediately while new image is analysed const img = new Image(); img.onload = () => { const size = 20; const canvas = document.createElement('canvas'); - canvas.width = size; canvas.height = size; + canvas.width = size; + canvas.height = size; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.drawImage(img, 0, 0, size, size); @@ -19,8 +23,11 @@ export function useDominantColor(src: string | null, { setGlobal = false } = {}) // Score every pixel const candidates: { r: number; g: number; b: number; score: number; sat: number; lum: number }[] = []; for (let i = 0; i < data.length; i += 4) { - const r = data[i] / 255, g = data[i + 1] / 255, b = data[i + 2] / 255; - const max = Math.max(r, g, b), min = Math.min(r, g, b); + const r = data[i] / 255, + g = data[i + 1] / 255, + b = data[i + 2] / 255; + const max = Math.max(r, g, b), + min = Math.min(r, g, b); const sat = max === 0 ? 0 : (max - min) / max; const lum = (max + min) / 2; const score = sat * (1 - Math.abs(lum - 0.45) * 1.8); @@ -30,7 +37,7 @@ export function useDominantColor(src: string | null, { setGlobal = false } = {}) // Sort best-first, then pick the first truly vibrant pixel. // Falls back to the raw top scorer if the whole image is dark/grey. candidates.sort((a, b) => b.score - a.score); - const vibrant = candidates.find(c => c.sat > 0.2 && c.lum > 0.18 && c.lum < 0.85); + const vibrant = candidates.find((c) => c.sat > 0.2 && c.lum > 0.18 && c.lum < 0.85); const pick = vibrant ?? candidates[0]; setColor(`${pick.r}, ${pick.g}, ${pick.b}`); @@ -45,7 +52,9 @@ export function useDominantColor(src: string | null, { setGlobal = false } = {}) } else { document.documentElement.style.removeProperty('--panel-color'); } - return () => { document.documentElement.style.removeProperty('--panel-color'); }; + return () => { + document.documentElement.style.removeProperty('--panel-color'); + }; }, [color, setGlobal]); return color; diff --git a/apps/desktop/renderer/src/hooks/useGeniusAlbumYear.ts b/apps/desktop/renderer/src/hooks/useGeniusAlbumYear.ts index e0d7cbd..4ba3495 100644 --- a/apps/desktop/renderer/src/hooks/useGeniusAlbumYear.ts +++ b/apps/desktop/renderer/src/hooks/useGeniusAlbumYear.ts @@ -2,22 +2,18 @@ import { useQuery } from '@tanstack/react-query'; export function geniusAlbumYearQueryOptions( albumName: string | null | undefined, - artistName: string | null | undefined, + artistName: string | null | undefined ) { return { queryKey: ['genius-album-year', albumName, artistName] as const, - queryFn: (): Promise => - window.sonos.geniusAlbumYear(albumName!, artistName!), + queryFn: (): Promise => window.sonos.geniusAlbumYear(albumName!, artistName!), staleTime: Infinity, retry: false, enabled: !!albumName && !!artistName, }; } -export function useGeniusAlbumYear( - albumName: string | null | undefined, - artistName: string | null | undefined, -) { +export function useGeniusAlbumYear(albumName: string | null | undefined, artistName: string | null | undefined) { const { data } = useQuery(geniusAlbumYearQueryOptions(albumName, artistName)); return data ?? null; } diff --git a/apps/desktop/renderer/src/hooks/useGeniusDescription.ts b/apps/desktop/renderer/src/hooks/useGeniusDescription.ts index 8059576..0b9b402 100644 --- a/apps/desktop/renderer/src/hooks/useGeniusDescription.ts +++ b/apps/desktop/renderer/src/hooks/useGeniusDescription.ts @@ -2,24 +2,18 @@ import { useQuery } from '@tanstack/react-query'; export function geniusDescriptionQueryOptions( trackName: string | null | undefined, - artistName: string | null | undefined, + artistName: string | null | undefined ) { return { queryKey: ['genius-description', trackName, artistName] as const, - queryFn: (): Promise => - window.sonos.geniusDescription(trackName!, artistName!), + queryFn: (): Promise => window.sonos.geniusDescription(trackName!, artistName!), staleTime: Infinity, retry: false, enabled: !!trackName && !!artistName, }; } -export function useGeniusDescription( - trackName: string | null | undefined, - artistName: string | null | undefined, -) { - const { data, isLoading } = useQuery( - geniusDescriptionQueryOptions(trackName, artistName), - ); +export function useGeniusDescription(trackName: string | null | undefined, artistName: string | null | undefined) { + const { data, isLoading } = useQuery(geniusDescriptionQueryOptions(trackName, artistName)); return { description: data ?? null, isLoading }; } diff --git a/apps/desktop/renderer/src/hooks/useImage.ts b/apps/desktop/renderer/src/hooks/useImage.ts index 0f8bf95..cae07e3 100644 --- a/apps/desktop/renderer/src/hooks/useImage.ts +++ b/apps/desktop/renderer/src/hooks/useImage.ts @@ -5,10 +5,16 @@ export function useImage(url: string | null | undefined): string | null { const [src, setSrc] = useState(() => (url ? getCached(url) : null)); useEffect(() => { - if (!url) { setSrc(null); return; } + if (!url) { + setSrc(null); + return; + } // Hit synchronously first (may already be cached from a prior render) const cached = getCached(url); - if (cached) { setSrc(cached); return; } + if (cached) { + setSrc(cached); + return; + } // Clear immediately so the stale image doesn't linger while the new one loads setSrc(null); @@ -16,7 +22,9 @@ export function useImage(url: string | null | undefined): string | null { fetchImage(url).then((objectUrl) => { if (!cancelled) setSrc(objectUrl); }); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [url]); return src; diff --git a/apps/desktop/renderer/src/hooks/useLyrics.ts b/apps/desktop/renderer/src/hooks/useLyrics.ts index f502e00..ed53403 100644 --- a/apps/desktop/renderer/src/hooks/useLyrics.ts +++ b/apps/desktop/renderer/src/hooks/useLyrics.ts @@ -25,7 +25,7 @@ export function lyricsQueryOptions( trackName: string | null | undefined, artistName: string | null | undefined, albumName: string | null | undefined, - durationMs: number, + durationMs: number ) { return { queryKey: ['lyrics', trackName, artistName, albumName] as const, @@ -48,7 +48,7 @@ export function useLyrics( trackName: string | null | undefined, artistName: string | null | undefined, albumName: string | null | undefined, - durationMs: number, + durationMs: number ) { const { data, isLoading } = useQuery(lyricsQueryOptions(trackName, artistName, albumName, durationMs)); diff --git a/apps/desktop/renderer/src/hooks/useMediaSession.ts b/apps/desktop/renderer/src/hooks/useMediaSession.ts index 09978d8..aad744a 100644 --- a/apps/desktop/renderer/src/hooks/useMediaSession.ts +++ b/apps/desktop/renderer/src/hooks/useMediaSession.ts @@ -1,7 +1,7 @@ -import { useEffect, useRef } from "react"; -import { getActiveProvider } from "../providers"; -import { useTrackDetails } from "./useTrackDetails"; -import type { PlaybackState } from "./usePlayback"; +import { useEffect, useRef } from 'react'; +import { getActiveProvider } from '../providers'; +import { useTrackDetails } from './useTrackDetails'; +import type { PlaybackState } from './usePlayback'; /** * Publishes the current track to the OS media overlay (Windows System Media @@ -27,7 +27,7 @@ export function useMediaSession(playback: PlaybackState) { const { data: td } = useTrackDetails( playback.currentObjectId ?? undefined, playback.currentServiceId ?? undefined, - playback.currentAccountId ?? undefined, + playback.currentAccountId ?? undefined ); const trackName = td?.trackName ?? playback.trackName; const artistName = td?.artist ?? playback.artistName; @@ -36,7 +36,7 @@ export function useMediaSession(playback: PlaybackState) { const ensureSilentLoop = () => { if (audioCtxRef.current) return audioElRef.current; - if (typeof window === "undefined" || !("AudioContext" in window)) return null; + if (typeof window === 'undefined' || !('AudioContext' in window)) return null; const ctx = new AudioContext(); const oscillator = ctx.createOscillator(); const gain = ctx.createGain(); @@ -57,7 +57,7 @@ export function useMediaSession(playback: PlaybackState) { // Keep the silent session alive/suspended in step with whether anything is "now playing". useEffect(() => { - if (!("mediaSession" in navigator)) return; + if (!('mediaSession' in navigator)) return; if (playback.isVisible) { const audio = ensureSilentLoop(); @@ -67,7 +67,7 @@ export function useMediaSession(playback: PlaybackState) { audioElRef.current?.pause(); audioCtxRef.current?.suspend().catch(() => {}); navigator.mediaSession.metadata = null; - navigator.mediaSession.playbackState = "none"; + navigator.mediaSession.playbackState = 'none'; } }, [playback.isVisible]); @@ -78,16 +78,18 @@ export function useMediaSession(playback: PlaybackState) { // actually registers with GlobalSystemMediaTransportControlsSessionManager; // the navigator.mediaSession calls below only cover in-browser/Chromium // behavior and cannot reach the OS on their own (Electron limitation). - window.sonos?.updateNowPlayingSmtc?.({ - title: trackName || "True Tunes", - artist: artistName || undefined, - album: albumName || undefined, - artworkUrl: artUrl || undefined, - }).catch(() => {}); + window.sonos + ?.updateNowPlayingSmtc?.({ + title: trackName || 'True Tunes', + artist: artistName || undefined, + album: albumName || undefined, + artworkUrl: artUrl || undefined, + }) + .catch(() => {}); - if (!("mediaSession" in navigator)) return; + if (!('mediaSession' in navigator)) return; navigator.mediaSession.metadata = new MediaMetadata({ - title: trackName || "True Tunes", + title: trackName || 'True Tunes', artist: artistName || undefined, album: albumName || undefined, artwork: artUrl ? [{ src: artUrl }] : [], @@ -96,13 +98,13 @@ export function useMediaSession(playback: PlaybackState) { // Playback state — drives the play/pause glyph in the OS overlay useEffect(() => { - if (!("mediaSession" in navigator) || !playback.isVisible) return; - navigator.mediaSession.playbackState = playback.isPlaying ? "playing" : "paused"; + if (!('mediaSession' in navigator) || !playback.isVisible) return; + navigator.mediaSession.playbackState = playback.isPlaying ? 'playing' : 'paused'; }, [playback.isVisible, playback.isPlaying]); // Position/duration — drives the scrub bar in the OS overlay useEffect(() => { - if (!("mediaSession" in navigator) || !playback.isVisible) return; + if (!('mediaSession' in navigator) || !playback.isVisible) return; if (!playback.durationMs || playback.durationMs <= 0) return; try { navigator.mediaSession.setPositionState({ @@ -119,24 +121,42 @@ export function useMediaSession(playback: PlaybackState) { // Transport action handlers — wired once, always delegate to the active provider useEffect(() => { - if (!("mediaSession" in navigator)) return; + if (!('mediaSession' in navigator)) return; const provider = () => getActiveProvider(); - navigator.mediaSession.setActionHandler("play", () => { provider().play().catch(() => {}); }); - navigator.mediaSession.setActionHandler("pause", () => { provider().pause().catch(() => {}); }); - navigator.mediaSession.setActionHandler("previoustrack", () => { provider().skipPrev().catch(() => {}); }); - navigator.mediaSession.setActionHandler("nexttrack", () => { provider().skipNext().catch(() => {}); }); - navigator.mediaSession.setActionHandler("seekto", (details) => { + navigator.mediaSession.setActionHandler('play', () => { + provider() + .play() + .catch(() => {}); + }); + navigator.mediaSession.setActionHandler('pause', () => { + provider() + .pause() + .catch(() => {}); + }); + navigator.mediaSession.setActionHandler('previoustrack', () => { + provider() + .skipPrev() + .catch(() => {}); + }); + navigator.mediaSession.setActionHandler('nexttrack', () => { + provider() + .skipNext() + .catch(() => {}); + }); + navigator.mediaSession.setActionHandler('seekto', (details) => { if (details.seekTime == null) return; - provider().seek(Math.floor(details.seekTime * 1000)).catch(() => {}); + provider() + .seek(Math.floor(details.seekTime * 1000)) + .catch(() => {}); }); return () => { - navigator.mediaSession.setActionHandler("play", null); - navigator.mediaSession.setActionHandler("pause", null); - navigator.mediaSession.setActionHandler("previoustrack", null); - navigator.mediaSession.setActionHandler("nexttrack", null); - navigator.mediaSession.setActionHandler("seekto", null); + navigator.mediaSession.setActionHandler('play', null); + navigator.mediaSession.setActionHandler('pause', null); + navigator.mediaSession.setActionHandler('previoustrack', null); + navigator.mediaSession.setActionHandler('nexttrack', null); + navigator.mediaSession.setActionHandler('seekto', null); }; }, []); diff --git a/apps/desktop/renderer/src/hooks/useNowPlaying.ts b/apps/desktop/renderer/src/hooks/useNowPlaying.ts index a3aa05e..159c49f 100644 --- a/apps/desktop/renderer/src/hooks/useNowPlaying.ts +++ b/apps/desktop/renderer/src/hooks/useNowPlaying.ts @@ -8,28 +8,40 @@ import type { SonosItem } from '../types/sonos'; export function useNowPlaying(playback: PlaybackState) { const { - artUrl, trackName, artistName, timeLabel, - progressPct, durationMs, isPlaying, isVisible, - shuffle, repeat, volume, isExplicit, - currentObjectId, currentServiceId, currentAccountId, - currentAlbumId, currentAlbumName, + artUrl, + trackName, + artistName, + timeLabel, + progressPct, + durationMs, + isPlaying, + isVisible, + shuffle, + repeat, + volume, + isExplicit, + currentObjectId, + currentServiceId, + currentAccountId, + currentAlbumId, + currentAlbumName, } = playback; const { data: td } = useTrackDetails( currentObjectId ?? undefined, currentServiceId ?? undefined, - currentAccountId ?? undefined, + currentAccountId ?? undefined ); - const cachedArt = useImage(td?.artUrl ?? artUrl); + const cachedArt = useImage(td?.artUrl ?? artUrl); const dominantColor = useDominantColor(cachedArt); - const displayTrack = td?.trackName ?? trackName; - const displayArtist = td?.artist ?? artistName; - const albumName = td?.albumName ?? currentAlbumName; - const albumId = td?.albumId ?? currentAlbumId; - const svcId = td?.serviceId ?? currentServiceId ?? undefined; - const accId = td?.accountId ?? currentAccountId ?? undefined; + const displayTrack = td?.trackName ?? trackName; + const displayArtist = td?.artist ?? artistName; + const albumName = td?.albumName ?? currentAlbumName; + const albumId = td?.albumId ?? currentAlbumId; + const svcId = td?.serviceId ?? currentServiceId ?? undefined; + const accId = td?.accountId ?? currentAccountId ?? undefined; const [elapsedLabel = '', durationLabel = ''] = timeLabel ? timeLabel.split(' / ') : []; @@ -40,27 +52,42 @@ export function useNowPlaying(playback: PlaybackState) { } }; - const albumItem: SonosItem | null = albumId ? { - title: albumName ?? '', - type: 'ITEM_ALBUM', - resource: { type: 'ALBUM', id: { objectId: albumId, serviceId: svcId, accountId: accId } }, - } as SonosItem : null; + const albumItem: SonosItem | null = albumId + ? ({ + title: albumName ?? '', + type: 'ITEM_ALBUM', + resource: { type: 'ALBUM', id: { objectId: albumId, serviceId: svcId, accountId: accId } }, + } as SonosItem) + : null; const artistId = td?.artistId; - const artistItem: SonosItem | null = artistId ? { - title: displayArtist ?? '', - type: 'ARTIST', - resource: { type: 'ARTIST', id: { objectId: artistId, serviceId: svcId, accountId: accId } }, - } as SonosItem : null; + const artistItem: SonosItem | null = artistId + ? ({ + title: displayArtist ?? '', + type: 'ARTIST', + resource: { type: 'ARTIST', id: { objectId: artistId, serviceId: svcId, accountId: accId } }, + } as SonosItem) + : null; return { // Display values - displayTrack, displayArtist, albumName, albumId, - cachedArt, dominantColor, - elapsedLabel, durationLabel, + displayTrack, + displayArtist, + albumName, + albumId, + cachedArt, + dominantColor, + elapsedLabel, + durationLabel, // Passthrough from playback state - progressPct, durationMs, isPlaying, isVisible, - shuffle, repeat, volume, isExplicit, + progressPct, + durationMs, + isPlaying, + isVisible, + shuffle, + repeat, + volume, + isExplicit, // Current track identity (for favouriting etc.) currentObjectId: currentObjectId ?? null, currentServiceId: svcId ?? null, diff --git a/apps/desktop/renderer/src/hooks/usePlayback.ts b/apps/desktop/renderer/src/hooks/usePlayback.ts index 9a9a2be..e6c33b0 100644 --- a/apps/desktop/renderer/src/hooks/usePlayback.ts +++ b/apps/desktop/renderer/src/hooks/usePlayback.ts @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { getActiveProvider } from "../providers"; -import type { NormalizedPlaybackState } from "../types/provider"; -import { fmtTime } from "../lib/itemHelpers"; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getActiveProvider } from '../providers'; +import type { NormalizedPlaybackState } from '../types/provider'; +import { fmtTime } from '../lib/itemHelpers'; export interface PlaybackState { isVisible: boolean; @@ -15,7 +15,7 @@ export interface PlaybackState { durationMs: number; isPlaying: boolean; shuffle: boolean; - repeat: "none" | "one" | "all"; + repeat: 'none' | 'one' | 'all'; volume: number; currentObjectId: string | null; currentServiceId: string | null; @@ -30,17 +30,17 @@ export interface PlaybackState { const IDLE_STATE: PlaybackState = { isVisible: false, - trackName: "", - artistName: "", + trackName: '', + artistName: '', artUrl: null, - stateIcon: "–", - timeLabel: "", + stateIcon: '–', + timeLabel: '', progressPct: 0, positionMs: 0, durationMs: 0, isPlaying: false, shuffle: false, - repeat: "none", + repeat: 'none', volume: 50, currentObjectId: null, currentServiceId: null, @@ -65,7 +65,7 @@ export function usePlayback(activeGroupId: string | null) { // Always-current queue cursors — updated immediately on every provider push, // before the !name guard and outside React batching, so callers never // read a version that's one message behind. - const queueIdRef = useRef(null); + const queueIdRef = useRef(null); const queueVersionRef = useRef(null); // Cache normalized state per groupId so we can restore on group switch @@ -78,8 +78,18 @@ export function usePlayback(activeGroupId: string | null) { }, [activeGroupId]); const applyState = useCallback((normalized: NormalizedPlaybackState) => { - const { status, isPlaying, positionMs, durationMs, currentTrack, - shuffle, repeatMode, queueId, queueVersion, queueItemId } = normalized; + const { + status, + isPlaying, + positionMs, + durationMs, + currentTrack, + shuffle, + repeatMode, + queueId, + queueVersion, + queueItemId, + } = normalized; const pct = durationMs > 0 ? Math.min((positionMs / durationMs) * 100, 100) : 0; @@ -98,11 +108,11 @@ export function usePlayback(activeGroupId: string | null) { // Still surface queue cursors into React state so useQueue can react to them // even when nothing is playing (no track name). if (queueId || queueVersion || queueItemId) { - setState(prev => ({ + setState((prev) => ({ ...prev, - ...(queueId ? { queueId } : {}), + ...(queueId ? { queueId } : {}), ...(queueVersion ? { queueVersion } : {}), - ...(queueItemId ? { queueItemId } : {}), + ...(queueItemId ? { queueItemId } : {}), })); } return; @@ -113,8 +123,8 @@ export function usePlayback(activeGroupId: string | null) { trackName: currentTrack.title, artistName: currentTrack.artist, artUrl: currentTrack.imageUrl, - stateIcon: status === 'playing' ? "▶" : status === 'paused' ? "⏸" : "–", - timeLabel: durationMs > 0 ? `${fmtTime(positionMs)} / ${fmtTime(durationMs)}` : "", + stateIcon: status === 'playing' ? '▶' : status === 'paused' ? '⏸' : '–', + timeLabel: durationMs > 0 ? `${fmtTime(positionMs)} / ${fmtTime(durationMs)}` : '', progressPct: pct, positionMs, durationMs, @@ -123,8 +133,7 @@ export function usePlayback(activeGroupId: string | null) { // Playback state doesn't carry volume — keep whatever the provider last delivered. volume: prev.volume, repeat: repeatMode, - currentObjectId: - currentTrack.id !== prev.currentObjectId ? currentTrack.id : prev.currentObjectId, + currentObjectId: currentTrack.id !== prev.currentObjectId ? currentTrack.id : prev.currentObjectId, currentServiceId: currentTrack.serviceId, currentAccountId: currentTrack.accountId, currentAlbumId: currentTrack.albumId, @@ -141,16 +150,13 @@ export function usePlayback(activeGroupId: string | null) { if (!state.isPlaying) return; const timer = setInterval(() => { const elapsed = Date.now() - lastUpdateAtRef.current; - const pos = Math.min( - positionMsRef.current + elapsed, - durationMsRef.current || 1, - ); + const pos = Math.min(positionMsRef.current + elapsed, durationMsRef.current || 1); const dur = durationMsRef.current; const pct = dur > 0 ? (pos / dur) * 100 : 0; setState((prev) => ({ ...prev, positionMs: pos, - timeLabel: dur > 0 ? `${fmtTime(pos)} / ${fmtTime(dur)}` : "", + timeLabel: dur > 0 ? `${fmtTime(pos)} / ${fmtTime(dur)}` : '', progressPct: pct, })); }, 1000); @@ -170,18 +176,17 @@ export function usePlayback(activeGroupId: string | null) { if (msgGroupId) groupCache.current.set(msgGroupId, normalized); // Only apply to UI if it's for the active group (or group isn't known yet) - if ( - msgGroupId && - activeGroupIdRef.current && - msgGroupId !== activeGroupIdRef.current - ) return; + if (msgGroupId && activeGroupIdRef.current && msgGroupId !== activeGroupIdRef.current) return; applyState(normalized); }, - (vol) => setState((prev) => ({ ...prev, volume: vol })), + (vol) => setState((prev) => ({ ...prev, volume: vol })) ); - return () => { unsubReady(); unsubPlayback(); }; + return () => { + unsubReady(); + unsubPlayback(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -209,11 +214,11 @@ export function usePlayback(activeGroupId: string | null) { setState({ ...IDLE_STATE, isVisible: true, - trackName: "Nothing playing", + trackName: 'Nothing playing', }); } }, - [applyState], + [applyState] ); return { playback: state, applyGroupCache, queueIdRef, queueVersionRef }; diff --git a/apps/desktop/renderer/src/hooks/usePlaylistBrowse.ts b/apps/desktop/renderer/src/hooks/usePlaylistBrowse.ts index 4e77eec..1beb472 100644 --- a/apps/desktop/renderer/src/hooks/usePlaylistBrowse.ts +++ b/apps/desktop/renderer/src/hooks/usePlaylistBrowse.ts @@ -40,7 +40,7 @@ export function playlistQueryOptions( playlistId: string | undefined, serviceId: string | undefined, accountId: string | undefined, - defaults?: string, + defaults?: string ) { return { queryKey: ['playlist', playlistId] as const, diff --git a/apps/desktop/renderer/src/hooks/usePlaylists.ts b/apps/desktop/renderer/src/hooks/usePlaylists.ts index ebc2e86..300c36a 100644 --- a/apps/desktop/renderer/src/hooks/usePlaylists.ts +++ b/apps/desktop/renderer/src/hooks/usePlaylists.ts @@ -39,7 +39,10 @@ export function useMyPlaylists(userName: string | null | undefined) { owned: owned.data ?? [], joined: joined.data ?? [], isLoading: owned.isLoading || joined.isLoading, - refetch: () => { owned.refetch(); joined.refetch(); }, + refetch: () => { + owned.refetch(); + joined.refetch(); + }, }; } @@ -73,7 +76,7 @@ export function useFavourite(displayName: string | null | undefined, track: FavT const { data: playlist } = usePlaylist(favId ?? undefined); - const isFavourited = !!(track?.uri && playlist?.tracks.some(t => t.uri === track.uri)); + const isFavourited = !!(track?.uri && playlist?.tracks.some((t) => t.uri === track.uri)); const toggle = useCallback(async () => { if (!favId || !track?.uri || !displayName) return; @@ -82,7 +85,7 @@ export function useFavourite(displayName: string | null | undefined, track: FavT if (snapshot) { queryClient.setQueryData(['playlist', favId], { ...snapshot, - tracks: snapshot.tracks.filter(t => t.uri !== track.uri), + tracks: snapshot.tracks.filter((t) => t.uri !== track.uri), }); } try { diff --git a/apps/desktop/renderer/src/hooks/usePrefetchNextLyrics.ts b/apps/desktop/renderer/src/hooks/usePrefetchNextLyrics.ts index 92e7af3..1a5fd4e 100644 --- a/apps/desktop/renderer/src/hooks/usePrefetchNextLyrics.ts +++ b/apps/desktop/renderer/src/hooks/usePrefetchNextLyrics.ts @@ -4,10 +4,7 @@ import { lyricsQueryOptions } from './useLyrics'; import { geniusDescriptionQueryOptions } from './useGeniusDescription'; import type { NormalizedQueueItem } from '../types/provider'; -export function usePrefetchNextLyrics( - items: NormalizedQueueItem[], - queueItemId: string | null, -) { +export function usePrefetchNextLyrics(items: NormalizedQueueItem[], queueItemId: string | null) { const queryClient = useQueryClient(); useEffect(() => { diff --git a/apps/desktop/renderer/src/hooks/useQueue.ts b/apps/desktop/renderer/src/hooks/useQueue.ts index 416c66c..84b83c8 100644 --- a/apps/desktop/renderer/src/hooks/useQueue.ts +++ b/apps/desktop/renderer/src/hooks/useQueue.ts @@ -9,16 +9,20 @@ export function useQueue( activeGroupId: string | null, queueId: string | null, onEtag?: (etag: string) => void, - onReloadError?: (msg: string) => void, + onReloadError?: (msg: string) => void ) { - const [items, setItems] = useState([]); + const [items, setItems] = useState([]); const [isLoading, setLoading] = useState(false); - const [error, setError] = useState(null); - const hasLoaded = useRef(false); - const onEtagRef = useRef(onEtag); - const onReloadErrorRef = useRef(onReloadError); - useEffect(() => { onEtagRef.current = onEtag; }); - useEffect(() => { onReloadErrorRef.current = onReloadError; }); + const [error, setError] = useState(null); + const hasLoaded = useRef(false); + const onEtagRef = useRef(onEtag); + const onReloadErrorRef = useRef(onReloadError); + useEffect(() => { + onEtagRef.current = onEtag; + }); + useEffect(() => { + onReloadErrorRef.current = onReloadError; + }); const load = useCallback(async () => { if (!activeGroupId) return; diff --git a/apps/desktop/renderer/src/hooks/useQueueTrack.ts b/apps/desktop/renderer/src/hooks/useQueueTrack.ts index cac2591..7f52401 100644 --- a/apps/desktop/renderer/src/hooks/useQueueTrack.ts +++ b/apps/desktop/renderer/src/hooks/useQueueTrack.ts @@ -13,34 +13,48 @@ export function useQueueTrack(item: NormalizedQueueItem, currentObjectId: string const { data } = useTrackDetails(trackId || undefined, serviceId ?? undefined, accountId ?? undefined); const isPlaying = !!currentObjectId && item.track.id === currentObjectId; - const artUrl = data?.artUrl ?? item.track.imageUrl; - const artist = data?.artist ?? item.track.artist; + const artUrl = data?.artUrl ?? item.track.imageUrl; + const artist = data?.artist ?? item.track.artist; const albumName = data?.albumName ?? item.track.albumName; - const albumId = data?.albumId ?? item.track.albumId; + const albumId = data?.albumId ?? item.track.albumId; const albumSvcId = data?.serviceId ?? serviceId; const albumAccId = data?.accountId ?? accountId; - const explicit = item.track.isExplicit; - - const albumItem: SonosItem | null = albumId && albumName ? { - title: albumName, - type: 'ITEM_ALBUM', - resource: { type: 'ALBUM', id: { objectId: albumId, serviceId: albumSvcId ?? '', accountId: albumAccId ?? '' } }, - } as SonosItem : null; - - const prefetchAlbum = albumId && albumSvcId && albumAccId - ? () => queryClient.prefetchQuery(albumQueryOptions(albumId, albumSvcId!, albumAccId!, undefined)) - : undefined; - - const artistId = data?.artistId ?? null; - const artistItem: SonosItem | null = artistId && artist ? { - name: artist, - type: 'ARTIST', - resource: { type: 'ARTIST', id: { objectId: artistId, serviceId: serviceId ?? '', accountId: accountId ?? '' } }, - } as SonosItem : null; - - const prefetchArtist = artistId && serviceId && accountId - ? () => queryClient.prefetchQuery(artistQueryOptions(artistId, serviceId, accountId, undefined)) - : undefined; + const explicit = item.track.isExplicit; + + const albumItem: SonosItem | null = + albumId && albumName + ? ({ + title: albumName, + type: 'ITEM_ALBUM', + resource: { + type: 'ALBUM', + id: { objectId: albumId, serviceId: albumSvcId ?? '', accountId: albumAccId ?? '' }, + }, + } as SonosItem) + : null; + + const prefetchAlbum = + albumId && albumSvcId && albumAccId + ? () => queryClient.prefetchQuery(albumQueryOptions(albumId, albumSvcId!, albumAccId!, undefined)) + : undefined; + + const artistId = data?.artistId ?? null; + const artistItem: SonosItem | null = + artistId && artist + ? ({ + name: artist, + type: 'ARTIST', + resource: { + type: 'ARTIST', + id: { objectId: artistId, serviceId: serviceId ?? '', accountId: accountId ?? '' }, + }, + } as SonosItem) + : null; + + const prefetchArtist = + artistId && serviceId && accountId + ? () => queryClient.prefetchQuery(artistQueryOptions(artistId, serviceId, accountId, undefined)) + : undefined; return { artUrl, artist, albumName, albumItem, prefetchAlbum, artistItem, prefetchArtist, isPlaying, explicit }; } diff --git a/apps/desktop/renderer/src/hooks/useRecentlyPlayed.ts b/apps/desktop/renderer/src/hooks/useRecentlyPlayed.ts index 07a54d4..c8ee9b4 100644 --- a/apps/desktop/renderer/src/hooks/useRecentlyPlayed.ts +++ b/apps/desktop/renderer/src/hooks/useRecentlyPlayed.ts @@ -83,7 +83,7 @@ export function useRecentlyPlayed(selectedUserId?: string) { const q = artistImageQueries[i]; return toArtistItem(a, q?.isSuccess ? (q.data ?? undefined) : undefined); }), - albumItems: (data?.albums ?? []).map(toAlbumItem), + albumItems: (data?.albums ?? []).map(toAlbumItem), availableUsers: data?.availableUsers ?? [], currentUserId: currentUserId ?? null, isLoading: !!userId && isLoading, diff --git a/apps/desktop/renderer/src/hooks/useResolveAndOpen.ts b/apps/desktop/renderer/src/hooks/useResolveAndOpen.ts index 2a8c79d..3a3e2ca 100644 --- a/apps/desktop/renderer/src/hooks/useResolveAndOpen.ts +++ b/apps/desktop/renderer/src/hooks/useResolveAndOpen.ts @@ -51,8 +51,7 @@ export function useResolveAndOpen() { // happily land on a same-named album/single by a different artist. let match: SonosItem | undefined; if (target === 'album' && hintArtists.length > 0) { - const matchesHint = (c: SonosItem) => - itemArtistNames(c).some((n) => hintArtists.includes(n)); + const matchesHint = (c: SonosItem) => itemArtistNames(c).some((n) => hintArtists.includes(n)); match = nameMatches.find(matchesHint) ?? candidates.find(matchesHint); } else if (target === 'artist' && lowerName) { match = nameMatches[0] ?? candidates[0]; diff --git a/apps/desktop/renderer/src/hooks/useUserProfile.ts b/apps/desktop/renderer/src/hooks/useUserProfile.ts index 0427284..2b1fda6 100644 --- a/apps/desktop/renderer/src/hooks/useUserProfile.ts +++ b/apps/desktop/renderer/src/hooks/useUserProfile.ts @@ -14,6 +14,5 @@ export function useUserProfile(userName: string | undefined) { export function useInvalidateUserProfile() { const queryClient = useQueryClient(); - return (userName: string) => - queryClient.invalidateQueries({ queryKey: ['user-profile', userName] }); + return (userName: string) => queryClient.invalidateQueries({ queryKey: ['user-profile', userName] }); } diff --git a/apps/desktop/renderer/src/hooks/useUserStats.ts b/apps/desktop/renderer/src/hooks/useUserStats.ts index c020658..8fdfd1d 100644 --- a/apps/desktop/renderer/src/hooks/useUserStats.ts +++ b/apps/desktop/renderer/src/hooks/useUserStats.ts @@ -35,10 +35,10 @@ export function useUserStats(userName: string | undefined, count?: number) { }); return { - topTracks: data?.topTracks ?? [], + topTracks: data?.topTracks ?? [], artistItems: (data?.topArtists ?? []).map(toArtistItem), - albumItems: (data?.topAlbums ?? []).map(toAlbumItem), + albumItems: (data?.topAlbums ?? []).map(toAlbumItem), totalEvents: data?.totalEvents ?? 0, - isLoading: !!userName && isLoading, + isLoading: !!userName && isLoading, }; } diff --git a/apps/desktop/renderer/src/lib/__tests__/gameGenerator.test.ts b/apps/desktop/renderer/src/lib/__tests__/gameGenerator.test.ts index 9baffcd..1ca9ad0 100644 --- a/apps/desktop/renderer/src/lib/__tests__/gameGenerator.test.ts +++ b/apps/desktop/renderer/src/lib/__tests__/gameGenerator.test.ts @@ -30,16 +30,37 @@ function manyEvents(args: { artist: args.artist, album: args.album, albumId: args.albumId, - }), + }) ); } describe('aggregateEvents', () => { it('counts plays per track, artist, album and records queuers', () => { const events = [ - ...manyEvents({ userId: 'alice', trackName: 'Dreams', artist: 'Fleetwood Mac', album: 'Rumours', albumId: 'alb-rumours', n: 3 }), - ...manyEvents({ userId: 'bob', trackName: 'Dreams', artist: 'Fleetwood Mac', album: 'Rumours', albumId: 'alb-rumours', n: 2 }), - ...manyEvents({ userId: 'alice', trackName: 'The Chain', artist: 'Fleetwood Mac', album: 'Rumours', albumId: 'alb-rumours', n: 1 }), + ...manyEvents({ + userId: 'alice', + trackName: 'Dreams', + artist: 'Fleetwood Mac', + album: 'Rumours', + albumId: 'alb-rumours', + n: 3, + }), + ...manyEvents({ + userId: 'bob', + trackName: 'Dreams', + artist: 'Fleetwood Mac', + album: 'Rumours', + albumId: 'alb-rumours', + n: 2, + }), + ...manyEvents({ + userId: 'alice', + trackName: 'The Chain', + artist: 'Fleetwood Mac', + album: 'Rumours', + albumId: 'alb-rumours', + n: 1, + }), ]; const agg = aggregateEvents(events); @@ -67,8 +88,22 @@ describe('aggregateEvents', () => { describe('aggregateEvents — eventType branching', () => { it("eventType 'track' bumps trackMap + artistMap + userCounts but not albumMap", () => { const events: RawEvent[] = [ - { eventType: 'track', userId: 'alice', trackName: 'Dreams', artist: 'Fleetwood Mac', album: 'Rumours', albumId: 'alb-rumours' }, - { eventType: 'track', userId: 'alice', trackName: 'The Chain', artist: 'Fleetwood Mac', album: 'Rumours', albumId: 'alb-rumours' }, + { + eventType: 'track', + userId: 'alice', + trackName: 'Dreams', + artist: 'Fleetwood Mac', + album: 'Rumours', + albumId: 'alb-rumours', + }, + { + eventType: 'track', + userId: 'alice', + trackName: 'The Chain', + artist: 'Fleetwood Mac', + album: 'Rumours', + albumId: 'alb-rumours', + }, ]; const agg = aggregateEvents(events); expect(agg.trackMap['Dreams||Fleetwood Mac'].count).toBe(1); @@ -80,7 +115,14 @@ describe('aggregateEvents — eventType branching', () => { it("eventType 'album' bumps albumMap only — no trackMap, artistMap, or userCounts", () => { const events: RawEvent[] = [ - { eventType: 'album', userId: 'alice', trackName: 'Rumours', artist: 'Fleetwood Mac', album: 'Rumours', albumId: 'alb-rumours' }, + { + eventType: 'album', + userId: 'alice', + trackName: 'Rumours', + artist: 'Fleetwood Mac', + album: 'Rumours', + albumId: 'alb-rumours', + }, ]; const agg = aggregateEvents(events); expect(agg.albumMap['alb-rumours'].count).toBe(1); @@ -103,13 +145,41 @@ describe('aggregateEvents — eventType branching', () => { it('an album add (1 album event + N track events) gives albumMap +1, artistMap +N, userCounts +N — never +N+1', () => { // Simulates queueing one 3-track album under the new scheme. const events: RawEvent[] = [ - { eventType: 'album', userId: 'alice', trackName: 'Rumours', artist: 'Fleetwood Mac', album: 'Rumours', albumId: 'alb-rumours' }, - { eventType: 'track', userId: 'alice', trackName: 'Dreams', artist: 'Fleetwood Mac', album: 'Rumours', albumId: 'alb-rumours' }, - { eventType: 'track', userId: 'alice', trackName: 'The Chain',artist: 'Fleetwood Mac', album: 'Rumours', albumId: 'alb-rumours' }, - { eventType: 'track', userId: 'alice', trackName: 'Go Your Own Way', artist: 'Fleetwood Mac', album: 'Rumours', albumId: 'alb-rumours' }, + { + eventType: 'album', + userId: 'alice', + trackName: 'Rumours', + artist: 'Fleetwood Mac', + album: 'Rumours', + albumId: 'alb-rumours', + }, + { + eventType: 'track', + userId: 'alice', + trackName: 'Dreams', + artist: 'Fleetwood Mac', + album: 'Rumours', + albumId: 'alb-rumours', + }, + { + eventType: 'track', + userId: 'alice', + trackName: 'The Chain', + artist: 'Fleetwood Mac', + album: 'Rumours', + albumId: 'alb-rumours', + }, + { + eventType: 'track', + userId: 'alice', + trackName: 'Go Your Own Way', + artist: 'Fleetwood Mac', + album: 'Rumours', + albumId: 'alb-rumours', + }, ]; const agg = aggregateEvents(events); - expect(agg.albumMap['alb-rumours'].count).toBe(1); // not 4 + expect(agg.albumMap['alb-rumours'].count).toBe(1); // not 4 expect(agg.artistMap['Fleetwood Mac'].count).toBe(3); // not 4 — album event must not contribute expect(Object.keys(agg.trackMap)).toHaveLength(3); expect(agg.userCounts.alice).toBe(3); // not 4 — album event must not bump userCounts @@ -129,7 +199,7 @@ describe('generateGame', () => { album: `Album ${i % 5}`, albumId: `alb-${i % 5}`, n: 6 + (i % 4), - }), + }) ); } return aggregateEvents(events); @@ -140,7 +210,7 @@ describe('generateGame', () => { const a = generateGame(agg, '2026-04-21'); const b = generateGame(agg, '2026-04-21'); expect(a.questions.map((q) => `${q.left.category}:${q.left.id}|${q.right.category}:${q.right.id}`)).toEqual( - b.questions.map((q) => `${q.left.category}:${q.left.id}|${q.right.category}:${q.right.id}`), + b.questions.map((q) => `${q.left.category}:${q.left.id}|${q.right.category}:${q.right.id}`) ); }); diff --git a/apps/desktop/renderer/src/lib/__tests__/imageCache.test.ts b/apps/desktop/renderer/src/lib/__tests__/imageCache.test.ts index f676b7f..2999123 100644 --- a/apps/desktop/renderer/src/lib/__tests__/imageCache.test.ts +++ b/apps/desktop/renderer/src/lib/__tests__/imageCache.test.ts @@ -85,7 +85,7 @@ describe('fetchImage', () => { it('deduplicates concurrent fetches for the same URL', async () => { let resolve!: () => void; - const pending = new Promise(res => { + const pending = new Promise((res) => { resolve = () => res(okResponse()); }); mockFetchGlobal.mockReturnValueOnce(pending); diff --git a/apps/desktop/renderer/src/lib/__tests__/itemHelpers.test.ts b/apps/desktop/renderer/src/lib/__tests__/itemHelpers.test.ts index e25804a..f5181b0 100644 --- a/apps/desktop/renderer/src/lib/__tests__/itemHelpers.test.ts +++ b/apps/desktop/renderer/src/lib/__tests__/itemHelpers.test.ts @@ -80,7 +80,11 @@ describe('getName', () => { }); it('falls back to resource.name', () => { - expect(getName({ resource: { name: 'Res', type: 'TRACK', id: { objectId: 'x', serviceId: 'y', accountId: 'z' } } } as SonosItem)).toBe('Res'); + expect( + getName({ + resource: { name: 'Res', type: 'TRACK', id: { objectId: 'x', serviceId: 'y', accountId: 'z' } }, + } as SonosItem) + ).toBe('Res'); }); it('falls back to track.name', () => { @@ -96,7 +100,9 @@ describe('getName', () => { describe('getItemArt', () => { it('returns tile1x1 from images object (browse shape)', () => { - expect(getItemArt({ images: { tile1x1: 'https://example.com/tile.jpg' } } as unknown as SonosItem)).toBe('https://example.com/tile.jpg'); + expect(getItemArt({ images: { tile1x1: 'https://example.com/tile.jpg' } } as unknown as SonosItem)).toBe( + 'https://example.com/tile.jpg' + ); }); it('returns imageUrl fallback', () => { @@ -104,7 +110,9 @@ describe('getItemArt', () => { }); it('returns track.imageUrl', () => { - expect(getItemArt({ track: { imageUrl: 'https://example.com/track.jpg' } } as SonosItem)).toBe('https://example.com/track.jpg'); + expect(getItemArt({ track: { imageUrl: 'https://example.com/track.jpg' } } as SonosItem)).toBe( + 'https://example.com/track.jpg' + ); }); it('returns null when no art', () => { @@ -120,7 +128,9 @@ describe('isAlbum', () => { }); it('identifies via resource.type', () => { - expect(isAlbum({ resource: { type: 'ALBUM', id: { objectId: '', serviceId: '', accountId: '' } } } as SonosItem)).toBe(true); + expect( + isAlbum({ resource: { type: 'ALBUM', id: { objectId: '', serviceId: '', accountId: '' } } } as SonosItem) + ).toBe(true); }); it('is case-insensitive', () => { @@ -207,9 +217,7 @@ describe('extractItems', () => { it('flattens .services into tagged items', () => { const data = { - services: [ - { serviceId: 'gm', results: { tracks: [item1] } }, - ], + services: [{ serviceId: 'gm', results: { tracks: [item1] } }], }; const result = extractItems(data); expect(result).toHaveLength(1); @@ -249,9 +257,7 @@ describe('parseServiceSearch', () => { const data: ServiceSearch = { resourceOrder: ['ARTISTS'], ARTISTS: { - resources: [ - { name: 'Queen', id: { objectId: 'art1', serviceId: 'gm', accountId: 'acc1' } }, - ], + resources: [{ name: 'Queen', id: { objectId: 'art1', serviceId: 'gm', accountId: 'acc1' } }], }, } as unknown as ServiceSearch; @@ -286,13 +292,15 @@ describe('parseServiceSearch', () => { const data = { resourceOrder: ['ALBUMS'], ALBUMS: { - resources: [{ - name: 'A Night at the Opera', - id: { objectId: 'alb1', serviceId: 'gm', accountId: 'acc1' }, - artists: [{ name: 'Queen', id: { objectId: 'art1', serviceId: 'gm', accountId: 'acc1' } }], - explicit: false, - summary: { content: 'Classic rock' }, - }], + resources: [ + { + name: 'A Night at the Opera', + id: { objectId: 'alb1', serviceId: 'gm', accountId: 'acc1' }, + artists: [{ name: 'Queen', id: { objectId: 'art1', serviceId: 'gm', accountId: 'acc1' } }], + explicit: false, + summary: { content: 'Classic rock' }, + }, + ], }, } as unknown as ServiceSearch; @@ -307,13 +315,15 @@ describe('parseServiceSearch', () => { const data = { resourceOrder: ['TRACKS'], TRACKS: { - resources: [{ - name: 'Bohemian Rhapsody', - id: { objectId: 'trk1', serviceId: 'gm', accountId: 'acc1' }, - artists: [{ name: 'Queen', id: { objectId: 'art1', serviceId: 'gm', accountId: 'acc1' } }], - explicit: false, - durationMs: 354000, - }], + resources: [ + { + name: 'Bohemian Rhapsody', + id: { objectId: 'trk1', serviceId: 'gm', accountId: 'acc1' }, + artists: [{ name: 'Queen', id: { objectId: 'art1', serviceId: 'gm', accountId: 'acc1' } }], + explicit: false, + durationMs: 354000, + }, + ], }, } as unknown as ServiceSearch; @@ -325,10 +335,12 @@ describe('parseServiceSearch', () => { const data = { resourceOrder: ['PLAYLISTS'], PLAYLISTS: { - resources: [{ - name: 'My Playlist', - id: { objectId: 'pl1', serviceId: 'gm', accountId: 'acc1' }, - }], + resources: [ + { + name: 'My Playlist', + id: { objectId: 'pl1', serviceId: 'gm', accountId: 'acc1' }, + }, + ], }, } as unknown as ServiceSearch; @@ -342,10 +354,12 @@ describe('parseServiceSearch', () => { const data = { resourceOrder: ['PODCASTS'], PODCASTS: { - resources: [{ - name: 'Tech Talk', - id: { objectId: 'pod1', serviceId: 'gm', accountId: 'acc1' }, - }], + resources: [ + { + name: 'Tech Talk', + id: { objectId: 'pod1', serviceId: 'gm', accountId: 'acc1' }, + }, + ], }, } as unknown as ServiceSearch; @@ -358,12 +372,12 @@ describe('parseServiceSearch', () => { it('uses default resourceOrder when not specified', () => { const data = { ARTISTS: { resources: [{ name: 'A', id: { objectId: 'a', serviceId: 's', accountId: 'c' } }] }, - ALBUMS: { resources: [{ name: 'B', id: { objectId: 'b', serviceId: 's', accountId: 'c' } }] }, - TRACKS: { resources: [{ name: 'C', id: { objectId: 'c', serviceId: 's', accountId: 'c' } }] }, + ALBUMS: { resources: [{ name: 'B', id: { objectId: 'b', serviceId: 's', accountId: 'c' } }] }, + TRACKS: { resources: [{ name: 'C', id: { objectId: 'c', serviceId: 's', accountId: 'c' } }] }, } as unknown as ServiceSearch; const result = parseServiceSearch(data); - expect(result.map(r => r.type)).toEqual(['ARTIST', 'ALBUM', 'TRACK']); + expect(result.map((r) => r.type)).toEqual(['ARTIST', 'ALBUM', 'TRACK']); }); }); diff --git a/apps/desktop/renderer/src/lib/__tests__/queueHelpers.test.ts b/apps/desktop/renderer/src/lib/__tests__/queueHelpers.test.ts index baa6f18..77f197c 100644 --- a/apps/desktop/renderer/src/lib/__tests__/queueHelpers.test.ts +++ b/apps/desktop/renderer/src/lib/__tests__/queueHelpers.test.ts @@ -54,7 +54,7 @@ describe('applyReorderLocally', () => { }); it('sorts fromIndices before moving — order of fromIndices array should not matter', () => { - const forward = applyReorderLocally(items, [0, 2], 4); + const forward = applyReorderLocally(items, [0, 2], 4); const reversed = applyReorderLocally(items, [2, 0], 4); expect(forward).toEqual(reversed); expect(forward).toEqual(['B', 'D', 'A', 'C', 'E']); diff --git a/apps/desktop/renderer/src/lib/__tests__/sonosApi.test.ts b/apps/desktop/renderer/src/lib/__tests__/sonosApi.test.ts index 4b7a851..75e2393 100644 --- a/apps/desktop/renderer/src/lib/__tests__/sonosApi.test.ts +++ b/apps/desktop/renderer/src/lib/__tests__/sonosApi.test.ts @@ -19,9 +19,7 @@ describe('defined() param filtering', () => { it('includes defined pathParams', async () => { await api.playback.getState('g:abc'); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ pathParams: { groupId: 'g:abc' } }) - ); + expect(mockFetch).toHaveBeenCalledWith(expect.objectContaining({ pathParams: { groupId: 'g:abc' } })); }); }); @@ -55,16 +53,12 @@ describe('api.playback', () => { it('pause sends correct operationId', async () => { await api.playback.pause('g:1'); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ operationId: 'pause' }) - ); + expect(mockFetch).toHaveBeenCalledWith(expect.objectContaining({ operationId: 'pause' })); }); it('skipNext sends skipToNextTrack operationId', async () => { await api.playback.skipNext('g:1'); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ operationId: 'skipToNextTrack' }) - ); + expect(mockFetch).toHaveBeenCalledWith(expect.objectContaining({ operationId: 'skipToNextTrack' })); }); it('setPlayMode sends body with mode flags', async () => { @@ -100,23 +94,17 @@ describe('api.queue', () => { it('add defaults position to -1 when not provided', async () => { await api.queue.add({}); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ query: { position: '-1' } }) - ); + expect(mockFetch).toHaveBeenCalledWith(expect.objectContaining({ query: { position: '-1' } })); }); it('add uses provided position', async () => { await api.queue.add({}, { position: 3 }); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ query: { position: '3' } }) - ); + expect(mockFetch).toHaveBeenCalledWith(expect.objectContaining({ query: { position: '3' } })); }); it('add includes If-Match header when ifMatch provided', async () => { await api.queue.add({}, { ifMatch: 'abc123' }); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ headers: { 'If-Match': 'abc123' } }) - ); + expect(mockFetch).toHaveBeenCalledWith(expect.objectContaining({ headers: { 'If-Match': 'abc123' } })); }); it('add does not include headers when ifMatch absent', async () => { @@ -127,9 +115,7 @@ describe('api.queue', () => { it('remove joins item indices with commas', async () => { await api.queue.remove([0, 2, 5]); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ query: { items: '0,2,5' } }) - ); + expect(mockFetch).toHaveBeenCalledWith(expect.objectContaining({ query: { items: '0,2,5' } })); }); it('reorder sends items and positions joined', async () => { @@ -230,9 +216,7 @@ describe('api.playback — remaining', () => { it('skipPrev sends skipToPreviousTrack operationId', async () => { await api.playback.skipPrev('g:1'); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ operationId: 'skipToPreviousTrack' }) - ); + expect(mockFetch).toHaveBeenCalledWith(expect.objectContaining({ operationId: 'skipToPreviousTrack' })); }); }); diff --git a/apps/desktop/renderer/src/lib/avatarImage.ts b/apps/desktop/renderer/src/lib/avatarImage.ts index b94e0d5..05ba9c7 100644 --- a/apps/desktop/renderer/src/lib/avatarImage.ts +++ b/apps/desktop/renderer/src/lib/avatarImage.ts @@ -44,7 +44,7 @@ export async function normalizeAvatar(file: File): Promise { ctx.drawImage(bitmap, sx, sy, side, side, 0, 0, target, target); const blob = await new Promise((resolve) => - canvas.toBlob((b) => resolve(b), 'image/webp', WEBP_QUALITY), + canvas.toBlob((b) => resolve(b), 'image/webp', WEBP_QUALITY) ); if (!blob) return passthrough(); return { buffer: await blob.arrayBuffer(), mimeType: 'image/webp' }; diff --git a/apps/desktop/renderer/src/lib/dragHelpers.ts b/apps/desktop/renderer/src/lib/dragHelpers.ts index e13238c..63aa3a6 100644 --- a/apps/desktop/renderer/src/lib/dragHelpers.ts +++ b/apps/desktop/renderer/src/lib/dragHelpers.ts @@ -1,10 +1,18 @@ export function createDragGhost(label: string, dataTransfer: DataTransfer): void { const ghost = document.createElement('div'); Object.assign(ghost.style, { - position: 'fixed', top: '-100px', left: '0', - background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)', - color: '#fff', padding: '5px 12px', borderRadius: '6px', - fontSize: '12px', fontWeight: '600', pointerEvents: 'none', whiteSpace: 'nowrap', + position: 'fixed', + top: '-100px', + left: '0', + background: 'rgba(255,255,255,0.15)', + backdropFilter: 'blur(8px)', + color: '#fff', + padding: '5px 12px', + borderRadius: '6px', + fontSize: '12px', + fontWeight: '600', + pointerEvents: 'none', + whiteSpace: 'nowrap', }); ghost.textContent = label; document.body.appendChild(ghost); diff --git a/apps/desktop/renderer/src/lib/imageCache.ts b/apps/desktop/renderer/src/lib/imageCache.ts index ab81056..e97eeba 100644 --- a/apps/desktop/renderer/src/lib/imageCache.ts +++ b/apps/desktop/renderer/src/lib/imageCache.ts @@ -2,11 +2,11 @@ // Prevents 429s when many images load at once, and revokes object URLs // when the cache exceeds MAX_ENTRIES to avoid memory leaks. -const MAX_ENTRIES = 200; +const MAX_ENTRIES = 200; const MAX_CONCURRENT = 4; // LRU: Map preserves insertion order; moving an entry to the end marks it as recently used. -const cache = new Map(); // url -> objectURL +const cache = new Map(); // url -> objectURL const inflight = new Map>(); // url -> in-flight promise const queue: Array<() => void> = []; let active = 0; @@ -49,7 +49,10 @@ export function fetchImage(url: string): Promise { active++; try { const resp = await fetch(url, { referrerPolicy: 'no-referrer' }); - if (!resp.ok) { resolve(null); return; } + if (!resp.ok) { + resolve(null); + return; + } const blob = await resp.blob(); evictLru(); const objectUrl = URL.createObjectURL(blob); @@ -59,7 +62,10 @@ export function fetchImage(url: string): Promise { // Direct fetch failed (likely CORS) — proxy through main process try { const result = await window.sonos.fetchImage(url); - if ('error' in result) { resolve(null); return; } + if ('error' in result) { + resolve(null); + return; + } const binary = atob(result.data); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); diff --git a/apps/desktop/renderer/src/lib/itemHelpers.ts b/apps/desktop/renderer/src/lib/itemHelpers.ts index 38fb61b..b2bbdd1 100644 --- a/apps/desktop/renderer/src/lib/itemHelpers.ts +++ b/apps/desktop/renderer/src/lib/itemHelpers.ts @@ -6,7 +6,11 @@ import type { NormalizedQueueItem } from '../types/provider'; export function decodeDefaults(defaults: string | undefined): Record | null { if (!defaults) return null; - try { return JSON.parse(atob(defaults)); } catch { return null; } + try { + return JSON.parse(atob(defaults)); + } catch { + return null; + } } // ─── Optimistic queue normaliser ───────────────────────────────────────────── @@ -22,8 +26,7 @@ export function normalizeForQueue(item: SonosItem): SonosItem { // If id is already an object the item is already normalised (e.g. search results) if (typeof item.id === 'object' || !rid?.objectId) return item; - const imageUrl = - (item.images as Record | undefined)?.['tile1x1'] ?? item.imageUrl; + const imageUrl = (item.images as Record | undefined)?.['tile1x1'] ?? item.imageUrl; const artistName = (item as Record)['subtitle'] ?? ''; return { @@ -43,12 +46,12 @@ export function normalizeForQueue(item: SonosItem): SonosItem { export function resolveAlbumParams(item: SonosItem) { const rid = item.resource?.id as SonosItemId | undefined; - const iid = typeof item.id === 'object' ? item.id as SonosItemId : undefined; + const iid = typeof item.id === 'object' ? (item.id as SonosItemId) : undefined; return { - albumId: rid?.objectId ?? iid?.objectId, + albumId: rid?.objectId ?? iid?.objectId, serviceId: rid?.serviceId ?? iid?.serviceId, accountId: (rid?.accountId ?? iid?.accountId)?.replace(/^sn_/, ''), - defaults: item.resource?.defaults as string | undefined, + defaults: item.resource?.defaults as string | undefined, }; } @@ -57,9 +60,9 @@ export function resolveAlbumParams(item: SonosItem) { export function extractItems(data: unknown): SonosItem[] { if (!data) return []; const d = data as Record; - if (Array.isArray(data)) return data as SonosItem[]; - if (Array.isArray(d['items'])) return d['items'] as SonosItem[]; - if (Array.isArray(d['resources'])) return d['resources'] as SonosItem[]; + if (Array.isArray(data)) return data as SonosItem[]; + if (Array.isArray(d['items'])) return d['items'] as SonosItem[]; + if (Array.isArray(d['resources'])) return d['resources'] as SonosItem[]; // Container browse: { section: { items: [...] } } const sec = d['section'] as Record | undefined; if (sec && Array.isArray(sec['items'])) return sec['items'] as SonosItem[]; @@ -69,7 +72,7 @@ export function extractItems(data: unknown): SonosItem[] { const r = (svc['results'] ?? svc) as Record; for (const key of Object.keys(r)) { if (Array.isArray(r[key])) { - (r[key] as SonosItem[]).forEach(item => { + (r[key] as SonosItem[]).forEach((item) => { flat.push({ ...item, _svcId: svc['serviceId'] } as SonosItem); }); } @@ -96,8 +99,14 @@ function resolveArtistName(raw: unknown): string { export function getSub(item: SonosItem): string { // subtitle is used by container browse responses (e.g. album items have subtitle = artist name) - const raw = item?.artist ?? item?.primaryArtist ?? (item as Record)?.['subtitle'] - ?? item?.resource?.artist ?? item?.track?.artist ?? item?.description ?? ''; + const raw = + item?.artist ?? + item?.primaryArtist ?? + (item as Record)?.['subtitle'] ?? + item?.resource?.artist ?? + item?.track?.artist ?? + item?.description ?? + ''; const result = resolveArtistName(raw); return item?.summary?.content ?? result; } @@ -108,7 +117,10 @@ export function getArtist(item: QueueItem): string { // Sonos serves multi-artist credits as a single joined string ("Sonny Stitt, Kenny Garrett"). export function splitArtists(artist: string): string[] { - return artist.split(/,\s*/).map((s) => s.trim()).filter(Boolean); + return artist + .split(/,\s*/) + .map((s) => s.trim()) + .filter(Boolean); } export function getAlbum(item: QueueItem): string { @@ -121,11 +133,7 @@ export function getAlbum(item: QueueItem): string { } export function getArt(item: SonosItem): string | null { - return item?.track?.imageUrl - ?? item?.track?.images?.[0]?.url - ?? item?.imageUrl - ?? item?.images?.[0]?.url - ?? null; + return item?.track?.imageUrl ?? item?.track?.images?.[0]?.url ?? item?.imageUrl ?? item?.images?.[0]?.url ?? null; } /** Like getArt but also handles browse API items where images is { tile1x1 } not an array. */ @@ -158,8 +166,7 @@ export function isTrack(item: SonosItem): boolean { export function isContainer(item: SonosItem): boolean { const type = (item.resource?.type ?? item.type ?? '') as string; const t = type.toUpperCase(); - return t === 'ITEM_AUDIO' || t === 'ITEM_STUDIO' || t === 'ITEM_PLAYLIST' - || t === 'CONTAINER' || t === 'SECTION'; + return t === 'ITEM_AUDIO' || t === 'ITEM_STUDIO' || t === 'ITEM_PLAYLIST' || t === 'CONTAINER' || t === 'SECTION'; } /** Items played via WS loadContent (stations, radio mixes, programs). */ @@ -169,16 +176,16 @@ export function isProgram(item: SonosItem): boolean { } const TYPE_LABELS: Record = { - TRACK: 'Track', - ALBUM: 'Album', - ARTIST: 'Artist', - PLAYLIST: 'Playlist', - PODCAST: 'Podcast', - PROGRAM: 'Station', - AUDIO: 'Collection', - STUDIO: 'Studio Mix', - CONTAINER: '', - SECTION: '', + TRACK: 'Track', + ALBUM: 'Album', + ARTIST: 'Artist', + PLAYLIST: 'Playlist', + PODCAST: 'Podcast', + PROGRAM: 'Station', + AUDIO: 'Collection', + STUDIO: 'Studio Mix', + CONTAINER: '', + SECTION: '', }; function formatItemType(raw: string | undefined): string { @@ -189,7 +196,7 @@ function formatItemType(raw: string | undefined): string { export function browseSub(item: SonosItem): string { if (item?.summary?.content) return item.summary.content; - const type = formatItemType(item?.type); + const type = formatItemType(item?.type); const artists = item?.artists?.map((a) => a.name).join(', ') ?? getSub(item); return [type, artists].filter(Boolean).join(' \u2022 '); } @@ -198,7 +205,7 @@ export function browseSub(item: SonosItem): string { export function resolveArtistParams(item: SonosItem) { const rid = item.resource?.id as SonosItemId | undefined; - const iid = typeof item.id === 'object' ? item.id as SonosItemId : undefined; + const iid = typeof item.id === 'object' ? (item.id as SonosItemId) : undefined; const serviceId = rid?.serviceId ?? iid?.serviceId; const accountId = (rid?.accountId ?? iid?.accountId)?.replace(/^sn_/, ''); @@ -257,7 +264,7 @@ export function parseServiceSearch(data: ServiceSearch): SonosItem[] { type: 'ALBUM', name: r.name, images: r.images, - artists: r.artists?.map(a => ({ name: a.name, id: a.id })), + artists: r.artists?.map((a) => ({ name: a.name, id: a.id })), explicit: r.explicit, summary: r.summary, id: { objectId: r.id.objectId, serviceId: r.id.serviceId, accountId: r.id.accountId }, @@ -274,7 +281,7 @@ export function parseServiceSearch(data: ServiceSearch): SonosItem[] { type: 'TRACK', name: r.name, images: r.images, - artists: r.artists?.map(a => ({ name: a.name, id: a.id })), + artists: r.artists?.map((a) => ({ name: a.name, id: a.id })), explicit: r.explicit, durationMs: r.durationMs, id: { objectId: r.id.objectId, serviceId: r.id.serviceId, accountId: r.id.accountId }, @@ -327,13 +334,13 @@ export function sonosItemToNormalizedQueueItem(item: SonosItem, index: number): const albumObj = track?.album as { name?: string; id?: { objectId?: string } } | undefined; const rawArtist = track?.artist ?? track?.primaryArtist ?? item.artist ?? item.primaryArtist; const artistName = rawArtist - ? typeof rawArtist === 'object' ? (rawArtist as { name?: string }).name ?? '' : String(rawArtist) + ? typeof rawArtist === 'object' + ? ((rawArtist as { name?: string }).name ?? '') + : String(rawArtist) : ''; - const artUrls = [ - ...(track?.images?.map((i: { url: string }) => i.url) ?? []), - track?.imageUrl, - item.imageUrl, - ].filter((u): u is string => !!u); + const artUrls = [...(track?.images?.map((i: { url: string }) => i.url) ?? []), track?.imageUrl, item.imageUrl].filter( + (u): u is string => !!u + ); const imageUrl = artUrls.find((u) => u.startsWith('https://')) ?? null; return { diff --git a/apps/desktop/renderer/src/lib/queueHelpers.ts b/apps/desktop/renderer/src/lib/queueHelpers.ts index 880f775..d3c7b25 100644 --- a/apps/desktop/renderer/src/lib/queueHelpers.ts +++ b/apps/desktop/renderer/src/lib/queueHelpers.ts @@ -2,8 +2,8 @@ export function applyReorderLocally(items: T[], fromIndices: number[], toInde const selectedSet = new Set(fromIndices); const remaining = items.filter((_, i) => !selectedSet.has(i)); const origNonSelected = items.flatMap((_, i) => (selectedSet.has(i) ? [] : [i])); - const insertAt = origNonSelected.filter(i => i < toIndex).length; - const movers = [...fromIndices].sort((a, b) => a - b).map(i => items[i]); + const insertAt = origNonSelected.filter((i) => i < toIndex).length; + const movers = [...fromIndices].sort((a, b) => a - b).map((i) => items[i]); return [...remaining.slice(0, insertAt), ...movers, ...remaining.slice(insertAt)]; } @@ -16,11 +16,7 @@ export function applyReorderLocally(items: T[], fromIndices: number[], toInde // `albumIds` is aligned to the queue items. Callers must supply the merged album id // (e.g. NormalizedTrack values overlaid with whatever `useTrackDetails` resolved) // because Sonos doesn't reliably embed album info on raw queue rows. -export function expandToAlbumBlock( - itemCount: number, - anchor: number, - albumIds: (string | null)[], -): Set { +export function expandToAlbumBlock(itemCount: number, anchor: number, albumIds: (string | null)[]): Set { if (anchor < 0 || anchor >= itemCount) return new Set([anchor]); const anchorAlbum = albumIds[anchor]; if (!anchorAlbum) return new Set([anchor]); diff --git a/apps/desktop/renderer/src/lib/sonosApi.ts b/apps/desktop/renderer/src/lib/sonosApi.ts index 89337d4..d9d5fe2 100644 --- a/apps/desktop/renderer/src/lib/sonosApi.ts +++ b/apps/desktop/renderer/src/lib/sonosApi.ts @@ -25,16 +25,13 @@ function call( } function defined(obj: Record): Record { - return Object.fromEntries( - Object.entries(obj).filter((e): e is [string, string] => e[1] !== undefined) - ); + return Object.fromEntries(Object.entries(obj).filter((e): e is [string, string] => e[1] !== undefined)); } // ─── API surface ────────────────────────────────────────────────────────────── function buildApi() { return { - auth: { getDiscovery: () => call('getAuthDiscovery'), }, @@ -44,67 +41,142 @@ function buildApi() { }, playback: { - getState: (groupId?: string) => call('getPlaybackState', { pathParams: defined({ groupId }) }), - play: (groupId?: string) => call('play', { pathParams: defined({ groupId }) }), - pause: (groupId?: string) => call('pause', { pathParams: defined({ groupId }) }), - skipNext: (groupId?: string) => call('skipToNextTrack', { pathParams: defined({ groupId }) }), - skipPrev: (groupId?: string) => call('skipToPreviousTrack', { pathParams: defined({ groupId }) }), - setPlayMode: (body: { shuffle?: boolean; repeat?: boolean; repeatOne?: boolean; crossfade?: boolean }, groupId?: string) => - call('setPlayMode', { pathParams: defined({ groupId }), body }), + getState: (groupId?: string) => call('getPlaybackState', { pathParams: defined({ groupId }) }), + play: (groupId?: string) => call('play', { pathParams: defined({ groupId }) }), + pause: (groupId?: string) => call('pause', { pathParams: defined({ groupId }) }), + skipNext: (groupId?: string) => call('skipToNextTrack', { pathParams: defined({ groupId }) }), + skipPrev: (groupId?: string) => call('skipToPreviousTrack', { pathParams: defined({ groupId }) }), + setPlayMode: ( + body: { shuffle?: boolean; repeat?: boolean; repeatOne?: boolean; crossfade?: boolean }, + groupId?: string + ) => call('setPlayMode', { pathParams: defined({ groupId }), body }), }, queue: { - list: (params?: { groupId?: string; queueId?: string; serviceId?: string; accountId?: string; count?: number; offset?: number }) => + list: (params?: { + groupId?: string; + queueId?: string; + serviceId?: string; + accountId?: string; + count?: number; + offset?: number; + }) => call('getQueueResources', { - pathParams: defined({ groupId: params?.groupId, queueId: params?.queueId, serviceId: params?.serviceId, accountId: params?.accountId }), + pathParams: defined({ + groupId: params?.groupId, + queueId: params?.queueId, + serviceId: params?.serviceId, + accountId: params?.accountId, + }), query: defined({ count: params?.count?.toString(), offset: params?.offset?.toString() }), }), - add: (body: unknown, params?: { groupId?: string; queueId?: string; serviceId?: string; accountId?: string; position?: number; ifMatch?: string }) => + add: ( + body: unknown, + params?: { + groupId?: string; + queueId?: string; + serviceId?: string; + accountId?: string; + position?: number; + ifMatch?: string; + } + ) => call('addQueueResource', { - pathParams: defined({ groupId: params?.groupId, queueId: params?.queueId, serviceId: params?.serviceId, accountId: params?.accountId }), + pathParams: defined({ + groupId: params?.groupId, + queueId: params?.queueId, + serviceId: params?.serviceId, + accountId: params?.accountId, + }), query: { position: String(params?.position ?? -1) }, headers: params?.ifMatch ? { 'If-Match': params.ifMatch } : undefined, body, }), - remove: (items: number[], params?: { groupId?: string; queueId?: string; serviceId?: string; accountId?: string }) => + remove: ( + items: number[], + params?: { groupId?: string; queueId?: string; serviceId?: string; accountId?: string } + ) => call('deleteQueueResources', { - pathParams: defined({ groupId: params?.groupId, queueId: params?.queueId, serviceId: params?.serviceId, accountId: params?.accountId }), + pathParams: defined({ + groupId: params?.groupId, + queueId: params?.queueId, + serviceId: params?.serviceId, + accountId: params?.accountId, + }), query: { items: items.join(',') }, }), - reorder: (items: number[], positions: number[], body: unknown[], params?: { groupId?: string; queueId?: string; serviceId?: string; accountId?: string }) => + reorder: ( + items: number[], + positions: number[], + body: unknown[], + params?: { groupId?: string; queueId?: string; serviceId?: string; accountId?: string } + ) => call('reorderQueueResources', { - pathParams: defined({ groupId: params?.groupId, queueId: params?.queueId, serviceId: params?.serviceId, accountId: params?.accountId }), + pathParams: defined({ + groupId: params?.groupId, + queueId: params?.queueId, + serviceId: params?.serviceId, + accountId: params?.accountId, + }), query: { items: items.join(','), positions: positions.join(',') }, body, }), }, browse: { - album: (albumId: string, params?: { serviceId?: string; accountId?: string; defaults?: string; muse2?: boolean; count?: number; offset?: number }) => + album: ( + albumId: string, + params?: { + serviceId?: string; + accountId?: string; + defaults?: string; + muse2?: boolean; + count?: number; + offset?: number; + } + ) => call('browseAlbum', { pathParams: { albumId, ...defined({ serviceId: params?.serviceId, accountId: params?.accountId }) }, - query: defined({ defaults: params?.defaults, muse2: params?.muse2 ? 'true' : undefined, count: params?.count?.toString(), offset: params?.offset?.toString() }), + query: defined({ + defaults: params?.defaults, + muse2: params?.muse2 ? 'true' : undefined, + count: params?.count?.toString(), + offset: params?.offset?.toString(), + }), }), - artist: (artistId: string, params?: { serviceId?: string; accountId?: string; defaults?: string; muse2?: boolean }) => + artist: ( + artistId: string, + params?: { serviceId?: string; accountId?: string; defaults?: string; muse2?: boolean } + ) => call('browseArtist', { pathParams: { artistId, ...defined({ serviceId: params?.serviceId, accountId: params?.accountId }) }, query: defined({ defaults: params?.defaults, muse2: params?.muse2 ? 'true' : undefined }), }), - container: (containerId: string, params?: { serviceId?: string; accountId?: string; defaults?: string; muse2?: boolean }) => + container: ( + containerId: string, + params?: { serviceId?: string; accountId?: string; defaults?: string; muse2?: boolean } + ) => call('browseContainer', { pathParams: { containerId, ...defined({ serviceId: params?.serviceId, accountId: params?.accountId }) }, query: defined({ defaults: params?.defaults, muse2: params?.muse2 ? 'true' : undefined }), }), - playlist: (playlistId: string, params?: { serviceId?: string; accountId?: string; defaults?: string; muse2?: boolean; count?: number }) => + playlist: ( + playlistId: string, + params?: { serviceId?: string; accountId?: string; defaults?: string; muse2?: boolean; count?: number } + ) => call('browsePlaylist', { pathParams: { playlistId, ...defined({ serviceId: params?.serviceId, accountId: params?.accountId }) }, - query: defined({ defaults: params?.defaults, muse2: params?.muse2 ? 'true' : undefined, count: params?.count?.toString() }), + query: defined({ + defaults: params?.defaults, + muse2: params?.muse2 ? 'true' : undefined, + count: params?.count?.toString(), + }), }), catalogContainer: (containerId: string, params?: { serviceId?: string; accountId?: string; count?: number }) => @@ -158,7 +230,6 @@ function buildApi() { featureFlag: (key: string) => call('getOptimizelyConfig', { pathParams: { key } }), metrics: (events: unknown[]) => call('postMetrics', { body: events }), }, - }; } diff --git a/apps/desktop/renderer/src/main.tsx b/apps/desktop/renderer/src/main.tsx index 3bee3c9..b4b821d 100644 --- a/apps/desktop/renderer/src/main.tsx +++ b/apps/desktop/renderer/src/main.tsx @@ -7,7 +7,9 @@ import './styles/global.css'; class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { state = { error: null }; - static getDerivedStateFromError(error: Error) { return { error }; } + static getDerivedStateFromError(error: Error) { + return { error }; + } render() { if (this.state.error) { return ( diff --git a/apps/desktop/renderer/src/providers/AudioProvider.ts b/apps/desktop/renderer/src/providers/AudioProvider.ts index 9d72a52..c56e885 100644 --- a/apps/desktop/renderer/src/providers/AudioProvider.ts +++ b/apps/desktop/renderer/src/providers/AudioProvider.ts @@ -17,7 +17,10 @@ export interface AudioProvider { refreshPlayback(): Promise; // Queue - getQueue(params?: { count?: number; offset?: number }): Promise<{ items: NormalizedQueueItem[]; etag?: string; error?: string }>; + getQueue(params?: { + count?: number; + offset?: number; + }): Promise<{ items: NormalizedQueueItem[]; etag?: string; error?: string }>; removeFromQueue(indices: number[]): Promise; reorderQueue(fromIndices: number[], toIndex: number, queueLength: number): Promise; clearQueue(): Promise; @@ -31,7 +34,7 @@ export interface AudioProvider { // onState receives groupId (undefined if provider has no group concept) plus the normalized state subscribePlayback( onState: (groupId: string | undefined, state: NormalizedPlaybackState) => void, - onVolume: (volume: number) => void, + onVolume: (volume: number) => void ): Unsubscribe; subscribeGroups(onGroups: (groups: NormalizedGroup[]) => void): Unsubscribe; } diff --git a/apps/desktop/renderer/src/providers/sonos/SonosProvider.ts b/apps/desktop/renderer/src/providers/sonos/SonosProvider.ts index 29d6966..a243f4d 100644 --- a/apps/desktop/renderer/src/providers/sonos/SonosProvider.ts +++ b/apps/desktop/renderer/src/providers/sonos/SonosProvider.ts @@ -1,7 +1,12 @@ import { api } from '../../lib/sonosApi'; import { extractItems } from '../../lib/itemHelpers'; import type { AudioProvider } from '../AudioProvider'; -import type { NormalizedGroup, NormalizedPlaybackState, NormalizedQueueItem, NormalizedTrack } from '../../types/provider'; +import type { + NormalizedGroup, + NormalizedPlaybackState, + NormalizedQueueItem, + NormalizedTrack, +} from '../../types/provider'; import type { PlaybackPayload, SonosItem, SonosItemId, SonosTrack } from '../../types/sonos'; function resolveArtistName(raw: SonosTrack['artist']): string { @@ -11,10 +16,7 @@ function resolveArtistName(raw: SonosTrack['artist']): string { } function resolveImageUrl(track: SonosTrack): string | null { - const urls = [ - ...(track.images?.map((i) => i.url) ?? []), - track.imageUrl, - ].filter((u): u is string => !!u); + const urls = [...(track.images?.map((i) => i.url) ?? []), track.imageUrl].filter((u): u is string => !!u); return urls.find((u) => u.startsWith('https://')) ?? null; } @@ -40,9 +42,11 @@ export function normalizePlaybackPayload(payload: PlaybackPayload): NormalizedPl const stateVal = payload?.playback?.playbackState ?? ''; const repeatOne = payload?.playback?.playModes?.repeatOne ?? false; const repeatAll = payload?.playback?.playModes?.repeat ?? false; - const status = stateVal.includes('PLAYING') ? 'playing' as const - : stateVal.includes('PAUSED') ? 'paused' as const - : 'idle' as const; + const status = stateVal.includes('PLAYING') + ? ('playing' as const) + : stateVal.includes('PAUSED') + ? ('paused' as const) + : ('idle' as const); return { status, @@ -79,10 +83,7 @@ function normalizeSonosItemToQueueItem(item: SonosItem, index: number): Normaliz albumId: albumObj?.id?.objectId ?? null, imageUrl: rawArt ?? null, durationMs: track?.durationMillis ?? 0, - isExplicit: !!( - track?.explicit ?? - (track as Record | undefined)?.['isExplicit'] - ), + isExplicit: !!(track?.explicit ?? (track as Record | undefined)?.['isExplicit']), serviceId: id?.serviceId ?? null, accountId: id?.accountId ?? null, }, @@ -98,26 +99,45 @@ function throwOnIpcError(r: unknown): void { export class SonosProvider implements AudioProvider { readonly id = 'sonos' as const; - play() { return window.sonos.play() as Promise; } - pause() { return window.sonos.pause() as Promise; } - skipNext() { return window.sonos.skipNext() as Promise; } - skipPrev() { return window.sonos.skipPrev() as Promise; } - seek(ms: number) { return window.sonos.seek(ms) as Promise; } - loadContent(p: Record) { return window.sonos.loadContent(p); } - skipToTrack(n: number) { return window.sonos.skipToTrack(n) as Promise; } - refreshPlayback() { return window.sonos.refreshPlayback() as Promise; } + play() { + return window.sonos.play() as Promise; + } + pause() { + return window.sonos.pause() as Promise; + } + skipNext() { + return window.sonos.skipNext() as Promise; + } + skipPrev() { + return window.sonos.skipPrev() as Promise; + } + seek(ms: number) { + return window.sonos.seek(ms) as Promise; + } + loadContent(p: Record) { + return window.sonos.loadContent(p); + } + skipToTrack(n: number) { + return window.sonos.skipToTrack(n) as Promise; + } + refreshPlayback() { + return window.sonos.refreshPlayback() as Promise; + } setPlayModes(modes: { shuffle?: boolean; repeat?: 'none' | 'one' | 'all' }) { const payload: { shuffle?: boolean; repeat?: boolean; repeatOne?: boolean } = {}; if (modes.shuffle !== undefined) payload.shuffle = modes.shuffle; if (modes.repeat !== undefined) { - payload.repeat = modes.repeat === 'all'; + payload.repeat = modes.repeat === 'all'; payload.repeatOne = modes.repeat === 'one'; } return window.sonos.setPlayModes(payload) as Promise; } - async getQueue(params?: { count?: number; offset?: number }): Promise<{ items: NormalizedQueueItem[]; etag?: string; error?: string }> { + async getQueue(params?: { + count?: number; + offset?: number; + }): Promise<{ items: NormalizedQueueItem[]; etag?: string; error?: string }> { const r = await api.queue.list({ count: params?.count, offset: params?.offset }); if (r.error) return { items: [], error: r.error }; const raw = extractItems(r.data) as SonosItem[]; @@ -138,13 +158,19 @@ export class SonosProvider implements AudioProvider { return window.sonos.reorderQueue(from, to, len).then(throwOnIpcError); } - getActiveGroup() { return window.sonos.getActiveGroup(); } - setGroup(groupId: string) { return window.sonos.setGroup(groupId).then(() => undefined); } - setVolume(volume: number) { return window.sonos.setGroupVolume(volume) as Promise; } + getActiveGroup() { + return window.sonos.getActiveGroup(); + } + setGroup(groupId: string) { + return window.sonos.setGroup(groupId).then(() => undefined); + } + setVolume(volume: number) { + return window.sonos.setGroupVolume(volume) as Promise; + } subscribePlayback( onState: (groupId: string | undefined, state: NormalizedPlaybackState) => void, - onVolume: (volume: number) => void, + onVolume: (volume: number) => void ) { return window.sonos.onWsMessage((header, payload) => { const h = header as Record; @@ -172,7 +198,7 @@ export class SonosProvider implements AudioProvider { subscribeGroups(onGroups: (groups: NormalizedGroup[]) => void) { return window.sonos.onWsGroups((raw) => { const groups = (raw as Array<{ id: string; name: string; coordinatorId?: string }>).map( - (g): NormalizedGroup => ({ id: g.id, name: g.name, providerId: 'sonos', coordinatorId: g.coordinatorId }), + (g): NormalizedGroup => ({ id: g.id, name: g.name, providerId: 'sonos', coordinatorId: g.coordinatorId }) ); onGroups(groups); }); diff --git a/apps/desktop/renderer/src/styles/AlbumPanel.module.css b/apps/desktop/renderer/src/styles/AlbumPanel.module.css index 44ac66d..9ed4232 100644 --- a/apps/desktop/renderer/src/styles/AlbumPanel.module.css +++ b/apps/desktop/renderer/src/styles/AlbumPanel.module.css @@ -11,7 +11,6 @@ /* background applied via inline style from dominant colour */ } - .headerContent { display: flex; gap: 24px; @@ -19,43 +18,80 @@ } .artWrap { - width: 200px; height: 200px; + width: 200px; + height: 200px; border-radius: var(--r-md); - overflow: hidden; flex-shrink: 0; + overflow: hidden; + flex-shrink: 0; background: var(--bg-1); } -.art { width: 100%; height: 100%; object-fit: cover; display: block; } +.art { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} .artPh { - width: 100%; height: 100%; - display: flex; align-items: center; justify-content: center; - font-size: 48px; color: var(--text-3); + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 48px; + color: var(--text-3); } .meta { - display: flex; flex-direction: column; - gap: 6px; padding-bottom: 4px; min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + padding-bottom: 4px; + min-width: 0; } .albumTitle { - font-size: 28px; font-weight: 700; - letter-spacing: -0.5px; color: var(--text); line-height: 1.1; + font-size: 28px; + font-weight: 700; + letter-spacing: -0.5px; + color: var(--text); + line-height: 1.1; +} +.artist { + font-size: 15px; + font-weight: 500; + color: var(--text-2); +} +.artistLink { + cursor: pointer; +} +.artistLink:hover { + color: var(--text); + text-decoration: underline; +} +.metaLine { + font-size: 12px; + color: var(--text-3); + margin-top: 2px; } -.artist { font-size: 15px; font-weight: 500; color: var(--text-2); } -.artistLink { cursor: pointer; } -.artistLink:hover { color: var(--text); text-decoration: underline; } -.metaLine { font-size: 12px; color: var(--text-3); margin-top: 2px; } -.actions { display: flex; gap: 8px; margin-top: 10px; } +.actions { + display: flex; + gap: 8px; + margin-top: 10px; +} .addAlbumBtn { background: rgba(255, 255, 255, 0.1); border: 1px solid var(--border-2); color: var(--text); - font-size: 12px; font-weight: 600; + font-size: 12px; + font-weight: 600; font-family: inherit; padding: 7px 16px; border-radius: 20px; cursor: pointer; - transition: background 0.15s, border-color 0.15s; + transition: + background 0.15s, + border-color 0.15s; letter-spacing: 0.1px; } .addAlbumBtn:hover { @@ -64,7 +100,9 @@ } /* ── Track list / table ── */ -.tracks { padding: 0 28px 8px; } +.tracks { + padding: 0 28px 8px; +} .tableHeader { display: grid; @@ -74,146 +112,240 @@ padding: 6px 8px 8px; border-bottom: 1px solid var(--border); margin-bottom: 4px; - font-size: 11px; font-weight: 600; letter-spacing: 0.5px; - color: var(--text-3); text-transform: uppercase; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + color: var(--text-3); + text-transform: uppercase; } .trackRow { display: grid; grid-template-columns: var(--track-cols); - align-items: center; gap: 12px; - padding: 6px 8px; border-radius: var(--r-sm); - transition: background 0.12s; cursor: grab; + align-items: center; + gap: 12px; + padding: 6px 8px; + border-radius: var(--r-sm); + transition: background 0.12s; + cursor: grab; +} +.trackRow:active { + cursor: grabbing; +} +.trackRow:hover { + background: var(--bg-2); +} +.trackRow.selected { + background: rgba(255, 255, 255, 0.08); +} +.trackRow.selected:hover { + background: rgba(255, 255, 255, 0.12); } -.trackRow:active { cursor: grabbing; } -.trackRow:hover { background: var(--bg-2); } -.trackRow.selected { background: rgba(255,255,255,0.08); } -.trackRow.selected:hover { background: rgba(255,255,255,0.12); } .ordinal { text-align: right; - font-size: 13px; color: var(--text-3); + font-size: 13px; + color: var(--text-3); } .trackArtWrap { - width: 38px; height: 38px; + width: 38px; + height: 38px; border-radius: 4px; overflow: hidden; background: var(--bg-1); } -.trackArt { width: 100%; height: 100%; object-fit: cover; display: block; } -.trackArtPh { width: 100%; height: 100%; background: var(--bg-2); } +.trackArt { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.trackArtPh { + width: 100%; + height: 100%; + background: var(--bg-2); +} .trackName { - font-size: 13px; font-weight: 500; color: var(--text); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - display: flex; align-items: center; gap: 4px; min-width: 0; + font-size: 13px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 4px; + min-width: 0; } .trackArtist { - font-size: 12px; color: var(--text-2); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; + font-size: 12px; + color: var(--text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; } .trackAlbum { - font-size: 12px; color: var(--text-3); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; + font-size: 12px; + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; } .artistBtn { - background: none; border: none; padding: 0; - font-size: inherit; color: var(--text-2); - cursor: pointer; font-family: inherit; + background: none; + border: none; + padding: 0; + font-size: inherit; + color: var(--text-2); + cursor: pointer; + font-family: inherit; transition: color 0.12s; } -.artistBtn:hover { color: var(--text); text-decoration: underline; } +.artistBtn:hover { + color: var(--text); + text-decoration: underline; +} -.duration { font-size: 12px; color: var(--text-3); text-align: right; } +.duration { + font-size: 12px; + color: var(--text-3); + text-align: right; +} .addBtn { - width: 26px; height: 26px; background: none; - border: 1px solid var(--border); border-radius: 50%; - color: var(--text-3); cursor: pointer; - font-size: 16px; line-height: 1; - display: flex; align-items: center; justify-content: center; - transition: all 0.15s; opacity: 0; + width: 26px; + height: 26px; + background: none; + border: 1px solid var(--border); + border-radius: 50%; + color: var(--text-3); + cursor: pointer; + font-size: 16px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; + opacity: 0; +} +.trackRow:hover .addBtn { + opacity: 1; +} +.addBtn:hover { + border-color: var(--border-2); + color: var(--text-2); + background: var(--bg-2); } -.trackRow:hover .addBtn { opacity: 1; } -.addBtn:hover { border-color: var(--border-2); color: var(--text-2); background: var(--bg-2); } .msg { - color: var(--text-3); font-size: 14px; - text-align: center; padding: 40px 0; + color: var(--text-3); + font-size: 14px; + text-align: center; + padding: 40px 0; } /* ── Spinner ── */ .spinnerWrap { - display: flex; justify-content: center; padding: 16px 0 8px; + display: flex; + justify-content: center; + padding: 16px 0 8px; } .spinner { - width: 20px; height: 20px; + width: 20px; + height: 20px; border: 2px solid var(--border); border-top-color: var(--text-2); border-radius: 50%; animation: spin 0.7s linear infinite; } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } /* ── Skeleton tracks ── */ @keyframes pulse { - 0%, 100% { opacity: 0.3; } - 50% { opacity: 0.6; } + 0%, + 100% { + opacity: 0.3; + } + 50% { + opacity: 0.6; + } } .skeletonRow { display: grid; grid-template-columns: var(--track-cols); - align-items: center; gap: 12px; - padding: 6px 8px; border-radius: var(--r-sm); + align-items: center; + gap: 12px; + padding: 6px 8px; + border-radius: var(--r-sm); } .skeletonOrdinal { height: 12px; - background: var(--bg-2); border-radius: 3px; + background: var(--bg-2); + border-radius: 3px; animation: pulse 1.8s ease-in-out infinite; } .skeletonArt { - width: 38px; height: 38px; - background: var(--bg-2); border-radius: 4px; + width: 38px; + height: 38px; + background: var(--bg-2); + border-radius: 4px; animation: pulse 1.8s ease-in-out infinite; } .skeletonTitle { - height: 12px; background: var(--bg-2); border-radius: 3px; + height: 12px; + background: var(--bg-2); + border-radius: 3px; animation: pulse 1.8s ease-in-out infinite; } .skeletonSub { - height: 10px; background: var(--bg-2); border-radius: 3px; + height: 10px; + background: var(--bg-2); + border-radius: 3px; animation: pulse 1.8s ease-in-out infinite; } .skeletonDur { height: 10px; - background: var(--bg-2); border-radius: 3px; + background: var(--bg-2); + border-radius: 3px; animation: pulse 1.8s ease-in-out infinite; } .selBar { - position: sticky; bottom: 0; - display: flex; align-items: center; justify-content: space-between; + position: sticky; + bottom: 0; + display: flex; + align-items: center; + justify-content: space-between; padding: 10px 28px; background: var(--bg-1); border-top: 1px solid var(--border); - font-size: 13px; color: var(--text-2); + font-size: 13px; + color: var(--text-2); } .selAddBtn { - background: rgba(255,255,255,0.1); + background: rgba(255, 255, 255, 0.1); border: 1px solid var(--border-2); color: var(--text); - font-size: 12px; font-weight: 600; + font-size: 12px; + font-weight: 600; font-family: inherit; padding: 6px 14px; border-radius: 20px; cursor: pointer; - transition: background 0.15s, border-color 0.15s; + transition: + background 0.15s, + border-color 0.15s; } .selAddBtn:hover { - background: rgba(255,255,255,0.18); - border-color: rgba(255,255,255,0.3); + background: rgba(255, 255, 255, 0.18); + border-color: rgba(255, 255, 255, 0.3); } diff --git a/apps/desktop/renderer/src/styles/App.module.css b/apps/desktop/renderer/src/styles/App.module.css index 35f97f2..de34d64 100644 --- a/apps/desktop/renderer/src/styles/App.module.css +++ b/apps/desktop/renderer/src/styles/App.module.css @@ -6,8 +6,7 @@ background: radial-gradient(ellipse at 20% 50%, rgba(90, 50, 150, 0.7) 0%, transparent 60%), radial-gradient(ellipse at 80% 15%, rgba(25, 70, 130, 0.55) 0%, transparent 55%), - radial-gradient(ellipse at 55% 85%, rgba(20, 65, 110, 0.45) 0%, transparent 50%), - #0f0f14; + radial-gradient(ellipse at 55% 85%, rgba(20, 65, 110, 0.45) 0%, transparent 50%), #0f0f14; } .body { @@ -43,8 +42,14 @@ } @keyframes toastIn { - from { opacity: 0; transform: translateX(-50%) translateY(8px); } - to { opacity: 1; transform: translateX(-50%) translateY(0); } + from { + opacity: 0; + transform: translateX(-50%) translateY(8px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } } .entraSigningIn { diff --git a/apps/desktop/renderer/src/styles/ArtistPanel.module.css b/apps/desktop/renderer/src/styles/ArtistPanel.module.css index b40944a..b9acd21 100644 --- a/apps/desktop/renderer/src/styles/ArtistPanel.module.css +++ b/apps/desktop/renderer/src/styles/ArtistPanel.module.css @@ -78,7 +78,9 @@ letter-spacing: 0.3px; color: var(--text-3); cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: + color 0.15s, + border-color 0.15s; text-decoration: none; } .socialLink:hover { @@ -298,10 +300,18 @@ transition: background 0.12s; cursor: grab; } -.topSongRow:active { cursor: grabbing; } -.topSongRow:hover { background: var(--bg-2); } -.topSongSelected { background: rgba(255,255,255,0.08); } -.topSongSelected:hover { background: rgba(255,255,255,0.12); } +.topSongRow:active { + cursor: grabbing; +} +.topSongRow:hover { + background: var(--bg-2); +} +.topSongSelected { + background: rgba(255, 255, 255, 0.08); +} +.topSongSelected:hover { + background: rgba(255, 255, 255, 0.12); +} .topSongNum { width: 18px; @@ -323,8 +333,12 @@ } @keyframes waveformBounce { - 0% { transform: scaleY(0.2); } - 100% { transform: scaleY(1); } + 0% { + transform: scaleY(0.2); + } + 100% { + transform: scaleY(1); + } } .waveformBar { @@ -334,9 +348,18 @@ transform-origin: bottom; animation: waveformBounce 0.9s ease-in-out infinite alternate; } -.waveformBar:nth-child(1) { height: 55%; animation-delay: 0s; } -.waveformBar:nth-child(2) { height: 100%; animation-delay: -0.35s; } -.waveformBar:nth-child(3) { height: 70%; animation-delay: -0.18s; } +.waveformBar:nth-child(1) { + height: 55%; + animation-delay: 0s; +} +.waveformBar:nth-child(2) { + height: 100%; + animation-delay: -0.35s; +} +.waveformBar:nth-child(3) { + height: 70%; + animation-delay: -0.18s; +} .waveformPaused .waveformBar { animation-play-state: paused; @@ -537,8 +560,13 @@ /* ── Loading skeletons ── */ @keyframes pulse { - 0%, 100% { opacity: 0.35; } - 50% { opacity: 0.6; } + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 0.6; + } } .skeletonRow { @@ -604,7 +632,9 @@ align-items: center; justify-content: center; } -.albumCard:hover .albumOverlay { opacity: 1; } +.albumCard:hover .albumOverlay { + opacity: 1; +} .albumAddBtn { width: 40px; @@ -622,7 +652,9 @@ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); transition: transform 0.15s; } -.albumAddBtn:hover { transform: scale(1.1); } +.albumAddBtn:hover { + transform: scale(1.1); +} .albumArtPh { width: 100%; height: 100%; diff --git a/apps/desktop/renderer/src/styles/CardRow.module.css b/apps/desktop/renderer/src/styles/CardRow.module.css index 25ec5fb..3e69391 100644 --- a/apps/desktop/renderer/src/styles/CardRow.module.css +++ b/apps/desktop/renderer/src/styles/CardRow.module.css @@ -18,6 +18,11 @@ animation: pulse 1.8s ease-in-out infinite; } @keyframes pulse { - 0%, 100% { opacity: 0.35; } - 50% { opacity: 0.6; } + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 0.6; + } } diff --git a/apps/desktop/renderer/src/styles/ChangelogDialog.module.css b/apps/desktop/renderer/src/styles/ChangelogDialog.module.css index f6fdca0..b7375e6 100644 --- a/apps/desktop/renderer/src/styles/ChangelogDialog.module.css +++ b/apps/desktop/renderer/src/styles/ChangelogDialog.module.css @@ -63,9 +63,14 @@ padding: 5px 8px; border-radius: var(--r-sm); line-height: 1; - transition: color 0.15s, background 0.15s; + transition: + color 0.15s, + background 0.15s; +} +.close:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.07); } -.close:hover { color: var(--text); background: rgba(255, 255, 255, 0.07); } /* ── Two-panel body ── */ .body { @@ -101,8 +106,12 @@ position: relative; width: 100%; } -.sidebarItem:hover { background: rgba(255, 255, 255, 0.06); } -.sidebarItemActive { background: rgba(255, 255, 255, 0.1) !important; } +.sidebarItem:hover { + background: rgba(255, 255, 255, 0.06); +} +.sidebarItemActive { + background: rgba(255, 255, 255, 0.1) !important; +} .sidebarTag { font-size: 12px; @@ -177,10 +186,15 @@ align-items: center; padding: 4px; border-radius: var(--r-sm); - transition: color 0.1s, background 0.1s; + transition: + color 0.1s, + background 0.1s; flex-shrink: 0; } -.ghLink:hover { color: var(--text); background: rgba(255, 255, 255, 0.07); } +.ghLink:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.07); +} /* ── Notes ── */ .notes { @@ -204,9 +218,16 @@ margin: 1.2em 0 0.4em; line-height: 1.3; } -.notesText :global(h1) { font-size: 16px; } -.notesText :global(h2) { font-size: 14px; } -.notesText :global(h3), .notesText :global(h4) { font-size: 13px; } +.notesText :global(h1) { + font-size: 16px; +} +.notesText :global(h2) { + font-size: 14px; +} +.notesText :global(h3), +.notesText :global(h4) { + font-size: 13px; +} .notesText :global(p) { margin: 0 0 0.75em; @@ -256,7 +277,9 @@ color: #7dd3fc; text-decoration: none; } -.notesText :global(a:hover) { text-decoration: underline; } +.notesText :global(a:hover) { + text-decoration: underline; +} .noNotes { font-size: 12px; diff --git a/apps/desktop/renderer/src/styles/ContextMenu.module.css b/apps/desktop/renderer/src/styles/ContextMenu.module.css index 0a04e92..471b9d1 100644 --- a/apps/desktop/renderer/src/styles/ContextMenu.module.css +++ b/apps/desktop/renderer/src/styles/ContextMenu.module.css @@ -7,14 +7,22 @@ border-radius: var(--r-md); padding: 4px; min-width: 190px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.3); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.55), + 0 2px 8px rgba(0, 0, 0, 0.3); animation: menuIn 0.1s ease-out; user-select: none; } @keyframes menuIn { - from { opacity: 0; transform: scale(0.97); } - to { opacity: 1; transform: scale(1); } + from { + opacity: 0; + transform: scale(0.97); + } + to { + opacity: 1; + transform: scale(1); + } } .item { @@ -28,7 +36,9 @@ color: var(--text-2); position: relative; white-space: nowrap; - transition: background 0.08s, color 0.08s; + transition: + background 0.08s, + color 0.08s; } .item:hover { background: var(--bg-2); @@ -76,9 +86,11 @@ animation: menuIn 0.1s ease-out; } -.submenu::-webkit-scrollbar { width: 6px; } +.submenu::-webkit-scrollbar { + width: 6px; +} .submenu::-webkit-scrollbar-thumb { - background: rgba(255,255,255,0.15); + background: rgba(255, 255, 255, 0.15); border-radius: 3px; } diff --git a/apps/desktop/renderer/src/styles/FeedbackDialog.module.css b/apps/desktop/renderer/src/styles/FeedbackDialog.module.css index 0b73a5a..efcf71b 100644 --- a/apps/desktop/renderer/src/styles/FeedbackDialog.module.css +++ b/apps/desktop/renderer/src/styles/FeedbackDialog.module.css @@ -67,7 +67,10 @@ font-weight: 500; padding: 7px 0; cursor: pointer; - transition: background 0.12s, color 0.12s, border-color 0.12s; + transition: + background 0.12s, + color 0.12s, + border-color 0.12s; } .toggleBtn:hover { color: var(--text); diff --git a/apps/desktop/renderer/src/styles/HomePanel.module.css b/apps/desktop/renderer/src/styles/HomePanel.module.css index f5bcedc..f8b5bed 100644 --- a/apps/desktop/renderer/src/styles/HomePanel.module.css +++ b/apps/desktop/renderer/src/styles/HomePanel.module.css @@ -27,7 +27,11 @@ cursor: pointer; transition: all 0.12s; } -.sectionAction:hover { color: var(--text); border-color: var(--border-2); background: rgba(255,255,255,0.05); } +.sectionAction:hover { + color: var(--text); + border-color: var(--border-2); + background: rgba(255, 255, 255, 0.05); +} .playlistRow { display: flex; @@ -35,8 +39,13 @@ overflow-x: auto; padding-bottom: 8px; } -.playlistRow::-webkit-scrollbar { height: 6px; } -.playlistRow::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } +.playlistRow::-webkit-scrollbar { + height: 6px; +} +.playlistRow::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; +} .usersRow { display: flex; @@ -46,7 +55,9 @@ padding: 6px 4px 10px; margin: -6px -4px -10px; } -.usersRow::-webkit-scrollbar { height: 0; } +.usersRow::-webkit-scrollbar { + height: 0; +} .userChip { display: flex; @@ -60,7 +71,10 @@ flex-shrink: 0; font-family: inherit; } -.userChip:hover .userAvatar { transform: scale(1.07); box-shadow: 0 0 0 2px rgba(255,255,255,0.3); } +.userChip:hover .userAvatar { + transform: scale(1.07); + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3); +} .userAvatar { width: 72px; @@ -74,7 +88,9 @@ font-weight: 700; color: var(--text); overflow: hidden; - transition: transform 0.15s, box-shadow 0.15s; + transition: + transform 0.15s, + box-shadow 0.15s; flex-shrink: 0; } .userAvatar img { @@ -210,9 +226,15 @@ cursor: grab; transition: background 0.15s; } -.albumRow:active { cursor: grabbing; } -.albumRow:hover { background: rgba(255, 255, 255, 0.06); } -.albumRow:hover .albumAddBtn { opacity: 1; } +.albumRow:active { + cursor: grabbing; +} +.albumRow:hover { + background: rgba(255, 255, 255, 0.06); +} +.albumRow:hover .albumAddBtn { + opacity: 1; +} .albumAddBtn { margin-left: auto; @@ -230,9 +252,13 @@ align-items: center; justify-content: center; opacity: 0; - transition: opacity 0.15s, background 0.15s; + transition: + opacity 0.15s, + background 0.15s; +} +.albumAddBtn:hover { + background: rgba(255, 255, 255, 0.22); } -.albumAddBtn:hover { background: rgba(255, 255, 255, 0.22); } .albumArtWrap { width: 46px; @@ -248,9 +274,14 @@ object-fit: cover; display: block; } -.albumArtPh { width: 100%; height: 100%; } +.albumArtPh { + width: 100%; + height: 100%; +} -.albumInfo { overflow: hidden; } +.albumInfo { + overflow: hidden; +} .albumName { font-size: 13px; font-weight: 500; @@ -428,10 +459,18 @@ align-items: center; flex-shrink: 0; opacity: 0; - transition: opacity 0.12s, color 0.12s, background 0.12s; + transition: + opacity 0.12s, + color 0.12s, + background 0.12s; +} +.userDropdownLi:hover .profileLinkBtn { + opacity: 1; +} +.profileLinkBtn:hover { + color: var(--text); + background: rgba(255, 255, 255, 0.07); } -.userDropdownLi:hover .profileLinkBtn { opacity: 1; } -.profileLinkBtn:hover { color: var(--text); background: rgba(255, 255, 255, 0.07); } .userDropdownItem:hover { background: rgba(255, 255, 255, 0.07); diff --git a/apps/desktop/renderer/src/styles/LeaderboardPanel.module.css b/apps/desktop/renderer/src/styles/LeaderboardPanel.module.css index 753223f..3344f02 100644 --- a/apps/desktop/renderer/src/styles/LeaderboardPanel.module.css +++ b/apps/desktop/renderer/src/styles/LeaderboardPanel.module.css @@ -235,7 +235,9 @@ text-align: center; flex-shrink: 0; white-space: nowrap; - transition: background 0.15s, border-color 0.15s; + transition: + background 0.15s, + border-color 0.15s; } .rankTierIcon { @@ -247,23 +249,23 @@ } /* ── Tier colour accents for leaderboard rank pills ── */ -.rankTier[data-tier="skip-button-survivor"] { +.rankTier[data-tier='skip-button-survivor'] { border-color: rgba(100, 149, 237, 0.22); background: rgba(100, 149, 237, 0.08); } -.rankTier[data-tier="background-bopper"] { +.rankTier[data-tier='background-bopper'] { border-color: rgba(205, 127, 50, 0.22); background: rgba(205, 127, 50, 0.08); } -.rankTier[data-tier="aux-cable-apprentice"] { +.rankTier[data-tier='aux-cable-apprentice'] { border-color: rgba(0, 210, 211, 0.22); background: rgba(0, 210, 211, 0.08); } -.rankTier[data-tier="algorithm-whisperer"] { +.rankTier[data-tier='algorithm-whisperer'] { border-color: rgba(168, 85, 247, 0.22); background: rgba(168, 85, 247, 0.08); } -.rankTier[data-tier="playlist-prophet"] { +.rankTier[data-tier='playlist-prophet'] { border-color: rgba(234, 179, 8, 0.22); background: rgba(234, 179, 8, 0.08); } @@ -342,7 +344,9 @@ font-family: inherit; cursor: pointer; } -.userNameBtn:hover { text-decoration: underline; } +.userNameBtn:hover { + text-decoration: underline; +} .barWrap { flex: 1; @@ -513,9 +517,9 @@ .rankInfoDialog { width: 100%; height: 100%; - background: radial-gradient(ellipse at 30% 40%, rgba(70, 45, 110, 0.3) 0%, transparent 60%), - radial-gradient(ellipse at 70% 20%, rgba(25, 55, 95, 0.25) 0%, transparent 55%), - rgba(10, 10, 14, 0.92); + background: + radial-gradient(ellipse at 30% 40%, rgba(70, 45, 110, 0.3) 0%, transparent 60%), + radial-gradient(ellipse at 70% 20%, rgba(25, 55, 95, 0.25) 0%, transparent 55%), rgba(10, 10, 14, 0.92); backdrop-filter: blur(48px) saturate(200%) brightness(1.03); will-change: backdrop-filter; display: flex; @@ -568,7 +572,9 @@ align-items: center; justify-content: center; z-index: 1; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; } .rankInfoClose:hover { color: var(--text); @@ -605,7 +611,10 @@ border-radius: var(--r-lg); color: var(--text); flex: 1 1 0; - transition: background 0.2s, border-color 0.2s, transform 0.2s; + transition: + background 0.2s, + border-color 0.2s, + transform 0.2s; } .rankInfoRow:hover { @@ -649,43 +658,43 @@ } /* ── Tier colour accents for rank info cards ── */ -.rankInfoRow[data-tier="skip-button-survivor"] { +.rankInfoRow[data-tier='skip-button-survivor'] { border-color: rgba(100, 149, 237, 0.18); background: rgba(100, 149, 237, 0.04); } -.rankInfoRow[data-tier="skip-button-survivor"]:hover { +.rankInfoRow[data-tier='skip-button-survivor']:hover { border-color: rgba(100, 149, 237, 0.3); background: rgba(100, 149, 237, 0.08); } -.rankInfoRow[data-tier="background-bopper"] { +.rankInfoRow[data-tier='background-bopper'] { border-color: rgba(205, 127, 50, 0.18); background: rgba(205, 127, 50, 0.04); } -.rankInfoRow[data-tier="background-bopper"]:hover { +.rankInfoRow[data-tier='background-bopper']:hover { border-color: rgba(205, 127, 50, 0.3); background: rgba(205, 127, 50, 0.08); } -.rankInfoRow[data-tier="aux-cable-apprentice"] { +.rankInfoRow[data-tier='aux-cable-apprentice'] { border-color: rgba(0, 210, 211, 0.18); background: rgba(0, 210, 211, 0.04); } -.rankInfoRow[data-tier="aux-cable-apprentice"]:hover { +.rankInfoRow[data-tier='aux-cable-apprentice']:hover { border-color: rgba(0, 210, 211, 0.3); background: rgba(0, 210, 211, 0.08); } -.rankInfoRow[data-tier="algorithm-whisperer"] { +.rankInfoRow[data-tier='algorithm-whisperer'] { border-color: rgba(168, 85, 247, 0.18); background: rgba(168, 85, 247, 0.04); } -.rankInfoRow[data-tier="algorithm-whisperer"]:hover { +.rankInfoRow[data-tier='algorithm-whisperer']:hover { border-color: rgba(168, 85, 247, 0.3); background: rgba(168, 85, 247, 0.08); } -.rankInfoRow[data-tier="playlist-prophet"] { +.rankInfoRow[data-tier='playlist-prophet'] { border-color: rgba(234, 179, 8, 0.18); background: rgba(234, 179, 8, 0.04); } -.rankInfoRow[data-tier="playlist-prophet"]:hover { +.rankInfoRow[data-tier='playlist-prophet']:hover { border-color: rgba(234, 179, 8, 0.3); background: rgba(234, 179, 8, 0.08); } diff --git a/apps/desktop/renderer/src/styles/MiniPlayer.module.css b/apps/desktop/renderer/src/styles/MiniPlayer.module.css index 6ae3438..3dd99fc 100644 --- a/apps/desktop/renderer/src/styles/MiniPlayer.module.css +++ b/apps/desktop/renderer/src/styles/MiniPlayer.module.css @@ -26,7 +26,9 @@ -webkit-app-region: drag; user-select: none; - transition: box-shadow 0.4s ease, background 0.4s ease; + transition: + box-shadow 0.4s ease, + background 0.4s ease; } /* ── Content ── */ @@ -49,7 +51,7 @@ overflow: hidden; background: rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.12); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.40); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); } .art img { width: 100%; @@ -90,7 +92,7 @@ } .artist { font-size: 11px; - color: rgba(255, 255, 255, 0.50); + color: rgba(255, 255, 255, 0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -114,7 +116,7 @@ transform: translateY(-50%); height: 3px; border-radius: 2px; - background: rgba(255, 255, 255, 0.10); + background: rgba(255, 255, 255, 0.1); transition: height 0.15s; pointer-events: none; } @@ -129,7 +131,10 @@ height: 3px; border-radius: 2px; background: var(--accent, rgba(255, 255, 255, 0.65)); - transition: width 0.5s linear, height 0.15s, background 0.15s; + transition: + width 0.5s linear, + height 0.15s, + background 0.15s; pointer-events: none; } .progress:hover .progressFill { @@ -160,16 +165,23 @@ border-radius: 50%; border: none; background: none; - color: rgba(255, 255, 255, 0.70); + color: rgba(255, 255, 255, 0.7); cursor: pointer; display: flex; align-items: center; justify-content: center; - transition: background 0.12s, color 0.12s; + transition: + background 0.12s, + color 0.12s; -webkit-app-region: no-drag; } -.btn:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } -.btn:active { background: rgba(255, 255, 255, 0.20); } +.btn:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} +.btn:active { + background: rgba(255, 255, 255, 0.2); +} .playBtn { width: 34px; @@ -177,7 +189,9 @@ background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.16); } -.playBtn:hover { background: rgba(255, 255, 255, 0.22); } +.playBtn:hover { + background: rgba(255, 255, 255, 0.22); +} /* ── Close button ── * Tucked flush into the top-right corner. The outer corner radius matches the @@ -193,7 +207,7 @@ border: none; border-radius: 0 18px 0 8px; background: rgba(255, 255, 255, 0.07); - color: rgba(255, 255, 255, 0.40); + color: rgba(255, 255, 255, 0.4); cursor: pointer; display: flex; align-items: center; @@ -203,8 +217,13 @@ font-size: 10px; line-height: 1; flex-shrink: 0; - transition: background 0.12s, color 0.12s; + transition: + background 0.12s, + color 0.12s; -webkit-app-region: no-drag; z-index: 2; } -.closeBtn:hover { background: rgba(220, 50, 50, 0.55); color: #fff; } +.closeBtn:hover { + background: rgba(220, 50, 50, 0.55); + color: #fff; +} diff --git a/apps/desktop/renderer/src/styles/PlayerBar.module.css b/apps/desktop/renderer/src/styles/PlayerBar.module.css index b455640..47007d7 100644 --- a/apps/desktop/renderer/src/styles/PlayerBar.module.css +++ b/apps/desktop/renderer/src/styles/PlayerBar.module.css @@ -291,7 +291,7 @@ button.trackArtist:hover { /* Invisible bridge so hover stays continuous between icon and popover */ .volPopover::after { - content: ""; + content: ''; position: absolute; left: 0; right: 0; @@ -356,7 +356,9 @@ button.trackArtist:hover { display: inline-flex; align-items: center; justify-content: center; - transition: color 0.12s, background 0.12s; + transition: + color 0.12s, + background 0.12s; } .volStep:hover:not(:disabled) { color: #fff; diff --git a/apps/desktop/renderer/src/styles/PlaylistPanel.module.css b/apps/desktop/renderer/src/styles/PlaylistPanel.module.css index 3d804f8..fe5af8c 100644 --- a/apps/desktop/renderer/src/styles/PlaylistPanel.module.css +++ b/apps/desktop/renderer/src/styles/PlaylistPanel.module.css @@ -9,7 +9,7 @@ .header { padding: calc(var(--nav-h) + 24px) 28px 24px; - background: linear-gradient(to bottom, rgba(255,255,255,0.04), transparent); + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.04), transparent); } .headerContent { @@ -34,7 +34,9 @@ overflow: hidden; } -.coverArtOwner { cursor: pointer; } +.coverArtOwner { + cursor: pointer; +} .coverArtImg { position: absolute; @@ -56,8 +58,12 @@ opacity: 0; transition: opacity 0.15s; } -.coverArtOwner:hover .coverArtOverlay { opacity: 1; } -.coverArtUploading { opacity: 1 !important; } +.coverArtOwner:hover .coverArtOverlay { + opacity: 1; +} +.coverArtUploading { + opacity: 1 !important; +} .headerInfo { flex: 1; @@ -88,7 +94,9 @@ .playlistNameEditable { cursor: text; } -.playlistNameEditable:hover { opacity: 0.8; } +.playlistNameEditable:hover { + opacity: 0.8; +} .renameInput { font-size: 32px; @@ -124,9 +132,14 @@ cursor: pointer; font-weight: 500; } -.ownerLink:hover { color: var(--text); text-decoration: underline; } +.ownerLink:hover { + color: var(--text); + text-decoration: underline; +} -.metaDot { color: var(--text-3); } +.metaDot { + color: var(--text-3); +} .badge { font-size: 12px; @@ -152,7 +165,9 @@ cursor: pointer; transition: background 0.15s; } -.playAllBtn:hover { background: rgba(255, 255, 255, 0.18); } +.playAllBtn:hover { + background: rgba(255, 255, 255, 0.18); +} .joinBtn { background: none; @@ -166,8 +181,14 @@ cursor: pointer; transition: all 0.15s; } -.joinBtn:hover { border-color: var(--border-2); color: var(--text); background: rgba(255,255,255,0.05); } -.joined { color: var(--text-3); } +.joinBtn:hover { + border-color: var(--border-2); + color: var(--text); + background: rgba(255, 255, 255, 0.05); +} +.joined { + color: var(--text-3); +} .deleteBtn { background: none; @@ -181,7 +202,11 @@ cursor: pointer; transition: all 0.15s; } -.deleteBtn:hover { border-color: rgba(255, 80, 80, 0.7); color: rgba(255, 80, 80, 1); background: rgba(255,80,80,0.08); } +.deleteBtn:hover { + border-color: rgba(255, 80, 80, 0.7); + color: rgba(255, 80, 80, 1); + background: rgba(255, 80, 80, 0.08); +} .deleteConfirmBtn { background: rgba(255, 80, 80, 0.15); @@ -195,7 +220,9 @@ cursor: pointer; transition: all 0.15s; } -.deleteConfirmBtn:hover { background: rgba(255, 80, 80, 0.25); } +.deleteConfirmBtn:hover { + background: rgba(255, 80, 80, 0.25); +} .cancelBtn { background: none; @@ -209,7 +236,10 @@ cursor: pointer; transition: all 0.15s; } -.cancelBtn:hover { border-color: var(--border-2); color: var(--text-2); } +.cancelBtn:hover { + border-color: var(--border-2); + color: var(--text-2); +} /* ── Track table ─────────────────────────────────────────────────────────── */ @@ -243,10 +273,18 @@ transition: background 0.12s; cursor: grab; } -.trackRow:active { cursor: grabbing; } -.trackRow:hover { background: var(--bg-2); } -.trackRow:hover .addBtn { opacity: 1; } -.trackRow:hover .removeBtn { opacity: 1; } +.trackRow:active { + cursor: grabbing; +} +.trackRow:hover { + background: var(--bg-2); +} +.trackRow:hover .addBtn { + opacity: 1; +} +.trackRow:hover .removeBtn { + opacity: 1; +} .ordinalCell { display: flex; @@ -266,8 +304,12 @@ cursor: grab; line-height: 1; } -.trackRow:hover .dragHandle { display: block; } -.trackRow:hover .ordinal { display: none; } +.trackRow:hover .dragHandle { + display: block; +} +.trackRow:hover .ordinal { + display: none; +} .dropLine { height: 2px; @@ -284,8 +326,17 @@ background: var(--bg-1); flex-shrink: 0; } -.trackArt { width: 100%; height: 100%; object-fit: cover; display: block; } -.trackArtPh { width: 100%; height: 100%; background: var(--bg-2); } +.trackArt { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.trackArtPh { + width: 100%; + height: 100%; + background: var(--bg-2); +} .trackMain { display: flex; @@ -336,7 +387,10 @@ color: inherit; cursor: pointer; } -.addedByLink:hover { color: var(--text); text-decoration: underline; } +.addedByLink:hover { + color: var(--text); + text-decoration: underline; +} .trackControls { display: flex; @@ -362,7 +416,11 @@ opacity: 0; flex-shrink: 0; } -.addBtn:hover { border-color: var(--border-2); color: var(--text-2); background: var(--bg-2); } +.addBtn:hover { + border-color: var(--border-2); + color: var(--text-2); + background: var(--bg-2); +} .removeBtn { background: none; @@ -373,10 +431,14 @@ padding: 0 2px; line-height: 1; opacity: 0; - transition: color 0.12s, opacity 0.12s; + transition: + color 0.12s, + opacity 0.12s; flex-shrink: 0; } -.removeBtn:hover { color: rgba(255, 80, 80, 0.9); } +.removeBtn:hover { + color: rgba(255, 80, 80, 0.9); +} .emptyMsg { font-size: 13px; @@ -416,7 +478,11 @@ cursor: pointer; transition: all 0.12s; } -.memberChip:hover { border-color: var(--border-2); color: var(--text); background: rgba(255,255,255,0.05); } +.memberChip:hover { + border-color: var(--border-2); + color: var(--text); + background: rgba(255, 255, 255, 0.05); +} /* ── Skeleton ────────────────────────────────────────────────────────────── */ diff --git a/apps/desktop/renderer/src/styles/ProfilePanel.module.css b/apps/desktop/renderer/src/styles/ProfilePanel.module.css index 78838e6..77224d4 100644 --- a/apps/desktop/renderer/src/styles/ProfilePanel.module.css +++ b/apps/desktop/renderer/src/styles/ProfilePanel.module.css @@ -36,7 +36,9 @@ .avatarEditable { cursor: pointer; } -.avatarEditable:hover .avatarOverlay { opacity: 1; } +.avatarEditable:hover .avatarOverlay { + opacity: 1; +} .avatarImg { width: 100%; @@ -60,7 +62,9 @@ opacity: 0; transition: opacity 0.15s; } -.avatarOverlayActive { opacity: 1 !important; } +.avatarOverlayActive { + opacity: 1 !important; +} .headerInfo { display: flex; @@ -138,7 +142,9 @@ cursor: pointer; transition: color 0.12s; } -.signOutInline:hover { color: rgba(255, 80, 80, 1); } +.signOutInline:hover { + color: rgba(255, 80, 80, 1); +} .changeNameInline { background: none; @@ -151,7 +157,9 @@ cursor: pointer; transition: color 0.12s; } -.changeNameInline:hover { color: var(--text-2); } +.changeNameInline:hover { + color: var(--text-2); +} .renameForm { display: flex; @@ -173,10 +181,12 @@ outline: none; width: 260px; } -.renameInput:focus { border-color: rgba(255,255,255,0.35); } +.renameInput:focus { + border-color: rgba(255, 255, 255, 0.35); +} .renameBtn { - background: rgba(255,255,255,0.12); + background: rgba(255, 255, 255, 0.12); border: 1px solid var(--border-2); border-radius: var(--r-md); color: var(--text); @@ -187,8 +197,13 @@ cursor: pointer; transition: background 0.12s; } -.renameBtn:hover:not(:disabled) { background: rgba(255,255,255,0.2); } -.renameBtn:disabled { opacity: 0.4; cursor: default; } +.renameBtn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.2); +} +.renameBtn:disabled { + opacity: 0.4; + cursor: default; +} .renameCancelBtn { background: none; @@ -199,7 +214,9 @@ padding: 6px 8px; cursor: pointer; } -.renameCancelBtn:hover { color: var(--text-2); } +.renameCancelBtn:hover { + color: var(--text-2); +} .renameError { width: 100%; @@ -240,7 +257,11 @@ cursor: pointer; transition: all 0.12s; } -.sectionAction:hover { border-color: var(--border-2); color: var(--text); background: rgba(255,255,255,0.05); } +.sectionAction:hover { + border-color: var(--border-2); + color: var(--text); + background: rgba(255, 255, 255, 0.05); +} .subSectionTitle { font-size: 13px; @@ -288,7 +309,10 @@ opacity: 0.75; transition: opacity 0.12s; } -.trackLink:hover { opacity: 1; text-decoration: underline; } +.trackLink:hover { + opacity: 1; + text-decoration: underline; +} .countBadge { font-size: 12px; @@ -401,4 +425,3 @@ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 16px; } - diff --git a/apps/desktop/renderer/src/styles/QueueSidebar.module.css b/apps/desktop/renderer/src/styles/QueueSidebar.module.css index 94887b5..68abfb5 100644 --- a/apps/desktop/renderer/src/styles/QueueSidebar.module.css +++ b/apps/desktop/renderer/src/styles/QueueSidebar.module.css @@ -48,7 +48,9 @@ .resizeHandle { position: absolute; - top: 0; left: 0; bottom: 0; + top: 0; + left: 0; + bottom: 0; width: 16px; cursor: ew-resize; background: transparent; @@ -73,7 +75,9 @@ transition: opacity 0.2s; pointer-events: none; } -.resizeHandle:hover::after { opacity: 1; } +.resizeHandle:hover::after { + opacity: 1; +} :global(html.resizingQueue), :global(html.resizingQueue) * { @@ -98,7 +102,6 @@ padding: 12px 16px; } - .title { font-size: 14px; font-weight: 600; @@ -117,7 +120,11 @@ border-radius: 10px; } -.headerActions { display: flex; align-items: center; gap: 2px; } +.headerActions { + display: flex; + align-items: center; + gap: 2px; +} .clearConfirm { display: flex; @@ -137,7 +144,9 @@ cursor: pointer; transition: background 0.15s; } -.confirmYes:hover { background: #e74c3c; } +.confirmYes:hover { + background: #e74c3c; +} .confirmNo { padding: 3px 10px; border-radius: var(--r-sm); @@ -148,17 +157,28 @@ cursor: pointer; transition: background 0.15s; } -.confirmNo:hover { background: var(--bg-2); } +.confirmNo:hover { + background: var(--bg-2); +} .iconBtn { - background: none; border: none; - color: var(--text-3); cursor: pointer; - font-size: 14px; padding: 5px 8px; + background: none; + border: none; + color: var(--text-3); + cursor: pointer; + font-size: 14px; + padding: 5px 8px; border-radius: var(--r-sm); - display: flex; align-items: center; justify-content: center; - transition: all 0.15s; line-height: 1; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; + line-height: 1; +} +.iconBtn:hover { + color: var(--text-2); + background: var(--bg-2); } -.iconBtn:hover { color: var(--text-2); background: var(--bg-2); } /* ── Content ── */ .content { @@ -213,11 +233,22 @@ transition: opacity 0.2s; pointer-events: none; } -.row:hover::before { opacity: 1; } -.row:hover { background: rgba(255, 255, 255, 0.03); } -.row.selected { background: rgba(255,255,255,0.1); } -.row.selected:hover { background: rgba(255,255,255,0.13); } -.row.playing .name { color: #fff; font-weight: 600; } +.row:hover::before { + opacity: 1; +} +.row:hover { + background: rgba(255, 255, 255, 0.03); +} +.row.selected { + background: rgba(255, 255, 255, 0.1); +} +.row.selected:hover { + background: rgba(255, 255, 255, 0.13); +} +.row.playing .name { + color: #fff; + font-weight: 600; +} /* Drag handle — only visible on hover/selected */ .dragHandle { @@ -230,21 +261,47 @@ transition: opacity 0.1s; } .row:hover .dragHandle, -.row.selected .dragHandle { opacity: 1; } -.dragHandle:active { cursor: grabbing; } +.row.selected .dragHandle { + opacity: 1; +} +.dragHandle:active { + cursor: grabbing; +} /* Art */ -.artWrap { position: relative; width: 36px; height: 36px; flex-shrink: 0; } -.art { width: 36px; height: 36px; border-radius: 4px; object-fit: cover; display: block; background: var(--bg-1); } +.artWrap { + position: relative; + width: 36px; + height: 36px; + flex-shrink: 0; +} +.art { + width: 36px; + height: 36px; + border-radius: 4px; + object-fit: cover; + display: block; + background: var(--bg-1); +} .artPh { - width: 36px; height: 36px; border-radius: 4px; background: var(--bg-1); - display: flex; align-items: center; justify-content: center; - color: var(--text-3); font-size: 12px; + width: 36px; + height: 36px; + border-radius: 4px; + background: var(--bg-1); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-3); + font-size: 12px; } .overlay { - position: absolute; inset: 0; border-radius: 4px; - background: rgba(0,0,0,0.55); - display: flex; align-items: center; justify-content: center; + position: absolute; + inset: 0; + border-radius: 4px; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; pointer-events: none; } @@ -262,38 +319,72 @@ background: #fff; animation: eqBar 0.9s ease-in-out infinite alternate; } -.eq span:nth-child(1) { animation-duration: 0.7s; } -.eq span:nth-child(2) { animation-duration: 1.0s; animation-delay: 0.15s; } -.eq span:nth-child(3) { animation-duration: 0.8s; animation-delay: 0.05s; } +.eq span:nth-child(1) { + animation-duration: 0.7s; +} +.eq span:nth-child(2) { + animation-duration: 1s; + animation-delay: 0.15s; +} +.eq span:nth-child(3) { + animation-duration: 0.8s; + animation-delay: 0.05s; +} @keyframes eqBar { - from { height: 3px; opacity: 0.7; } - to { height: 13px; opacity: 1; } + from { + height: 3px; + opacity: 0.7; + } + to { + height: 13px; + opacity: 1; + } } /* Text */ -.text { flex: 1; min-width: 0; } +.text { + flex: 1; + min-width: 0; +} .name { - font-size: 12px; font-weight: 500; color: var(--text); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: 12px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .sub { - font-size: 11px; color: var(--text-2); margin-top: 1px; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-size: 11px; + color: var(--text-2); + margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .subAlbum { display: block; width: fit-content; max-width: 100%; - background: none; border: none; padding: 0; - font-size: 11px; font-family: inherit; + background: none; + border: none; + padding: 0; + font-size: 11px; + font-family: inherit; color: var(--text-2); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; text-align: left; - cursor: pointer; transition: color 0.12s; + cursor: pointer; + transition: color 0.12s; +} +.subAlbum:hover { + color: var(--text); + text-decoration: underline; } -.subAlbum:hover { color: var(--text); text-decoration: underline; } .attribution { font-size: 10px; @@ -313,7 +404,10 @@ color: inherit; cursor: pointer; } -.attributionUser:hover { color: var(--text); text-decoration: underline; } +.attributionUser:hover { + color: var(--text); + text-decoration: underline; +} .timeToPlay { font-size: 10px; @@ -328,7 +422,7 @@ .dropLine { height: 2px; margin: 0 8px; - background: rgba(255,255,255,0.5); + background: rgba(255, 255, 255, 0.5); border-radius: 1px; pointer-events: none; } @@ -343,7 +437,10 @@ flex-shrink: 0; } -.selBar span { font-size: 12px; color: var(--text-2); } +.selBar span { + font-size: 12px; + color: var(--text-2); +} .selActions { display: flex; @@ -353,41 +450,57 @@ .selDelBtn { background: none; - border: 1px solid rgba(255,80,80,0.4); - color: rgba(255,100,100,0.9); - font-size: 11px; font-weight: 600; font-family: inherit; - padding: 4px 12px; border-radius: 12px; - cursor: pointer; transition: all 0.15s; + border: 1px solid rgba(255, 80, 80, 0.4); + color: rgba(255, 100, 100, 0.9); + font-size: 11px; + font-weight: 600; + font-family: inherit; + padding: 4px 12px; + border-radius: 12px; + cursor: pointer; + transition: all 0.15s; } .selDelBtn:hover { - background: rgba(255,60,60,0.15); - border-color: rgba(255,80,80,0.7); + background: rgba(255, 60, 60, 0.15); + border-color: rgba(255, 80, 80, 0.7); color: #ff6464; } .selExpandBtn { background: none; - border: 1px solid rgba(255,255,255,0.2); + border: 1px solid rgba(255, 255, 255, 0.2); color: var(--text-2); - font-size: 11px; font-weight: 600; font-family: inherit; - padding: 4px 12px; border-radius: 12px; - cursor: pointer; transition: all 0.15s; + font-size: 11px; + font-weight: 600; + font-family: inherit; + padding: 4px 12px; + border-radius: 12px; + cursor: pointer; + transition: all 0.15s; } .selExpandBtn:hover { - background: rgba(255,255,255,0.08); - border-color: rgba(255,255,255,0.35); + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.35); color: var(--text); } /* ── Misc ── */ .msg { padding: 28px 16px; - color: var(--text-3); font-size: 13px; + color: var(--text-3); + font-size: 13px; text-align: center; } -@keyframes spin { to { transform: rotate(360deg); } } -.spinner { animation: spin 0.9s linear infinite; color: var(--text-3); } +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.spinner { + animation: spin 0.9s linear infinite; + color: var(--text-3); +} /* ── DJ autoplay: the break, then what's coming ──────────────────────────── */ @@ -419,7 +532,9 @@ cursor: pointer; padding: 2px 4px; border-radius: var(--r-sm); - transition: color 0.15s, background 0.15s; + transition: + color 0.15s, + background 0.15s; white-space: nowrap; } .djBreakLabel:hover { @@ -431,7 +546,9 @@ animation: djSpin 3s linear infinite; } @keyframes djSpin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } /* Upcoming picks reuse .row so they match the queue exactly; these only mark diff --git a/apps/desktop/renderer/src/styles/Queuedle.module.css b/apps/desktop/renderer/src/styles/Queuedle.module.css index b1fac62..d408660 100644 --- a/apps/desktop/renderer/src/styles/Queuedle.module.css +++ b/apps/desktop/renderer/src/styles/Queuedle.module.css @@ -1008,7 +1008,9 @@ cursor: pointer; text-align: left; } -.scoreNameBtn:hover { text-decoration: underline; } +.scoreNameBtn:hover { + text-decoration: underline; +} .scoreBreakdown { font-size: 11px; color: var(--text-3); diff --git a/apps/desktop/renderer/src/styles/SkinnyTransport.module.css b/apps/desktop/renderer/src/styles/SkinnyTransport.module.css index d5e7711..aa91c51 100644 --- a/apps/desktop/renderer/src/styles/SkinnyTransport.module.css +++ b/apps/desktop/renderer/src/styles/SkinnyTransport.module.css @@ -20,7 +20,7 @@ overflow: hidden; background: rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.12); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.40); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); } .art img { width: 100%; @@ -78,11 +78,13 @@ transform: translateY(-50%); height: 3px; border-radius: 2px; - background: rgba(255, 255, 255, 0.10); + background: rgba(255, 255, 255, 0.1); transition: height 0.15s; pointer-events: none; } -.progress:hover::before { height: 5px; } +.progress:hover::before { + height: 5px; +} .progressFill { position: absolute; left: 0; @@ -91,7 +93,10 @@ height: 3px; border-radius: 2px; background: #fff; - transition: width 0.5s linear, height 0.15s, background 0.15s; + transition: + width 0.5s linear, + height 0.15s, + background 0.15s; pointer-events: none; } .progress:hover .progressFill { @@ -121,16 +126,26 @@ border-radius: 50%; border: none; background: none; - color: rgba(255, 255, 255, 0.70); + color: rgba(255, 255, 255, 0.7); cursor: pointer; display: flex; align-items: center; justify-content: center; - transition: background 0.12s, color 0.12s; + transition: + background 0.12s, + color 0.12s; +} +.btn:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} +.btn:active { + background: rgba(255, 255, 255, 0.2); +} +.btn:disabled { + opacity: 0.4; + cursor: default; } -.btn:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } -.btn:active { background: rgba(255, 255, 255, 0.20); } -.btn:disabled { opacity: 0.4; cursor: default; } .playBtn { width: 36px; @@ -138,4 +153,6 @@ background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.16); } -.playBtn:hover { background: rgba(255, 255, 255, 0.22); } +.playBtn:hover { + background: rgba(255, 255, 255, 0.22); +} diff --git a/apps/desktop/renderer/src/styles/Splash.module.css b/apps/desktop/renderer/src/styles/Splash.module.css index a165e57..cc64225 100644 --- a/apps/desktop/renderer/src/styles/Splash.module.css +++ b/apps/desktop/renderer/src/styles/Splash.module.css @@ -50,15 +50,34 @@ background: var(--text-3); animation: dot 1.2s ease-in-out infinite; } -.dots span:nth-child(2) { animation-delay: 0.2s; } -.dots span:nth-child(3) { animation-delay: 0.4s; } +.dots span:nth-child(2) { + animation-delay: 0.2s; +} +.dots span:nth-child(3) { + animation-delay: 0.4s; +} @keyframes breathe { - 0%, 100% { opacity: 0.45; transform: scale(0.96); } - 50% { opacity: 1; transform: scale(1.04); } + 0%, + 100% { + opacity: 0.45; + transform: scale(0.96); + } + 50% { + opacity: 1; + transform: scale(1.04); + } } @keyframes dot { - 0%, 80%, 100% { opacity: 0.25; transform: translateY(0); } - 40% { opacity: 1; transform: translateY(-4px); } + 0%, + 80%, + 100% { + opacity: 0.25; + transform: translateY(0); + } + 40% { + opacity: 1; + transform: translateY(-4px); + } } diff --git a/apps/desktop/renderer/src/styles/TopNav.module.css b/apps/desktop/renderer/src/styles/TopNav.module.css index 15f1e47..399c2fb 100644 --- a/apps/desktop/renderer/src/styles/TopNav.module.css +++ b/apps/desktop/renderer/src/styles/TopNav.module.css @@ -59,7 +59,9 @@ display: flex; align-items: center; justify-content: center; - transition: color 0.12s, background 0.12s; + transition: + color 0.12s, + background 0.12s; } .historyBtn:hover:not(:disabled) { color: #fff; @@ -83,7 +85,8 @@ } /* ── Main nav pill ── */ -.nav {} +.nav { +} .inner { height: 100%; @@ -104,7 +107,9 @@ display: flex; align-items: center; justify-content: center; - transition: color 0.12s, background 0.12s; + transition: + color 0.12s, + background 0.12s; flex-shrink: 0; } .iconBtn:hover { @@ -148,8 +153,12 @@ outline: none; padding: 4px 0; } -.searchInput::placeholder { color: rgba(255, 255, 255, 0.35); } -.searchInput:disabled { opacity: 0.35; } +.searchInput::placeholder { + color: rgba(255, 255, 255, 0.35); +} +.searchInput:disabled { + opacity: 0.35; +} .clearBtn { background: none; @@ -163,10 +172,14 @@ transition: color 0.12s; flex-shrink: 0; } -.clearBtn:hover { color: rgba(255, 255, 255, 0.8); } +.clearBtn:hover { + color: rgba(255, 255, 255, 0.8); +} /* ── Group selector ── */ -.groupWrap { position: relative; } +.groupWrap { + position: relative; +} .groupPopover { position: absolute; @@ -202,14 +215,24 @@ padding: 7px 10px; border-radius: var(--r-sm); cursor: pointer; - transition: background 0.12s, color 0.12s; + transition: + background 0.12s, + color 0.12s; white-space: nowrap; } -.groupOption:hover { background: var(--bg-2); color: var(--text); } -.groupOptionActive { color: var(--text) !important; font-weight: 600; } +.groupOption:hover { + background: var(--bg-2); + color: var(--text); +} +.groupOptionActive { + color: var(--text) !important; + font-weight: 600; +} /* ── Profile chip + popover ── */ -.profileWrap { position: relative; } +.profileWrap { + position: relative; +} .profileChip { display: flex; @@ -224,9 +247,15 @@ flex-shrink: 0; max-width: 140px; } -.profileChip:hover { background: rgba(255, 255, 255, 0.08); } -.profileChipOpen { background: rgba(255, 255, 255, 0.10); } -.profileChipActive { background: rgba(255, 255, 255, 0.08); } +.profileChip:hover { + background: rgba(255, 255, 255, 0.08); +} +.profileChipOpen { + background: rgba(255, 255, 255, 0.1); +} +.profileChipActive { + background: rgba(255, 255, 255, 0.08); +} .profileAvatar { width: 22px; @@ -315,9 +344,14 @@ padding: 7px 10px; cursor: pointer; text-align: left; - transition: background 0.12s, color 0.12s; + transition: + background 0.12s, + color 0.12s; +} +.profileViewBtn:hover { + background: rgba(255, 255, 255, 0.12); + color: var(--text); } -.profileViewBtn:hover { background: rgba(255, 255, 255, 0.12); color: var(--text); } .profileDivider { height: 1px; @@ -349,7 +383,9 @@ text-align: left; transition: color 0.12s; } -.profileSignOutBtn:hover { color: rgba(255, 80, 80, 1); } +.profileSignOutBtn:hover { + color: rgba(255, 80, 80, 1); +} /* ── Name input (shared by profile popover) ── */ @@ -364,7 +400,9 @@ outline: none; transition: border-color 0.15s; } -.nameInput:focus { border-color: var(--border-2); } +.nameInput:focus { + border-color: var(--border-2); +} .nameSaveBtn { background: rgba(255, 255, 255, 0.1); @@ -378,8 +416,13 @@ cursor: pointer; transition: background 0.12s; } -.nameSaveBtn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.18); } -.nameSaveBtn:disabled { opacity: 0.35; cursor: default; } +.nameSaveBtn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.18); +} +.nameSaveBtn:disabled { + opacity: 0.35; + cursor: default; +} .queueModeToggle { display: flex; @@ -397,9 +440,14 @@ font-family: inherit; padding: 6px 0; cursor: pointer; - transition: background 0.12s, color 0.12s, border-color 0.12s; + transition: + background 0.12s, + color 0.12s, + border-color 0.12s; +} +.queueModeBtn:hover { + color: var(--text); } -.queueModeBtn:hover { color: var(--text); } .queueModeBtnActive { background: rgba(255, 255, 255, 0.12); border-color: var(--border-2); @@ -434,4 +482,6 @@ padding: 2px 6px; transition: background 0.12s; } -.updateBtn:hover { background: rgba(74, 222, 128, 0.12); } +.updateBtn:hover { + background: rgba(74, 222, 128, 0.12); +} diff --git a/apps/desktop/renderer/src/styles/WindowControls.module.css b/apps/desktop/renderer/src/styles/WindowControls.module.css index 1d92d52..2baecb6 100644 --- a/apps/desktop/renderer/src/styles/WindowControls.module.css +++ b/apps/desktop/renderer/src/styles/WindowControls.module.css @@ -8,7 +8,9 @@ display: flex; align-items: center; justify-content: center; - transition: color 0.12s, background 0.12s; + transition: + color 0.12s, + background 0.12s; -webkit-app-region: no-drag; } .winBtn:hover { @@ -24,7 +26,7 @@ display: flex; align-items: center; gap: 5px; - background: rgba(74, 222, 128, 0.10); + background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.45); color: #4ade80; cursor: pointer; @@ -37,4 +39,6 @@ transition: background 0.12s; -webkit-app-region: no-drag; } -.titleBarUpdateBtn:hover { background: rgba(74, 222, 128, 0.20); } +.titleBarUpdateBtn:hover { + background: rgba(74, 222, 128, 0.2); +} diff --git a/apps/desktop/renderer/src/styles/global.css b/apps/desktop/renderer/src/styles/global.css index b763c35..3b911f5 100644 --- a/apps/desktop/renderer/src/styles/global.css +++ b/apps/desktop/renderer/src/styles/global.css @@ -102,6 +102,11 @@ html[data-mini] #root { } @keyframes skelPulse { - 0%, 100% { opacity: 0.3; } - 50% { opacity: 0.55; } + 0%, + 100% { + opacity: 0.3; + } + 50% { + opacity: 0.55; + } } diff --git a/apps/desktop/renderer/src/test/setup.ts b/apps/desktop/renderer/src/test/setup.ts index 2cd7ed5..2dbf7a7 100644 --- a/apps/desktop/renderer/src/test/setup.ts +++ b/apps/desktop/renderer/src/test/setup.ts @@ -19,87 +19,91 @@ const pending = () => new Promise(() => {}); Object.defineProperty(window, 'sonos', { writable: true, value: { - fetchStats: vi.fn(pending), - fetchDailyGame: vi.fn(pending), - submitGameScore: vi.fn(pending), + fetchStats: vi.fn(pending), + fetchDailyGame: vi.fn(pending), + submitGameScore: vi.fn(pending), fetchGameLeaderboard: vi.fn(pending), - fetchGameDates: vi.fn(pending), - fetchMyScore: vi.fn(pending), - fetchGameStats: vi.fn(pending), - fetch: vi.fn(pending), - fetchImage: vi.fn(pending), - onAuthReady: vi.fn(noop), - onAuthExpired: vi.fn(noop), - onWsMessage: vi.fn(noop), - onWsReady: vi.fn(noop), - onWsGroups: vi.fn(noop), - onAttributionMap: vi.fn(noop), + fetchGameDates: vi.fn(pending), + fetchMyScore: vi.fn(pending), + fetchGameStats: vi.fn(pending), + fetch: vi.fn(pending), + fetchImage: vi.fn(pending), + onAuthReady: vi.fn(noop), + onAuthExpired: vi.fn(noop), + onWsMessage: vi.fn(noop), + onWsReady: vi.fn(noop), + onWsGroups: vi.fn(noop), + onAttributionMap: vi.fn(noop), onAttributionEvent: vi.fn(noop), - getActiveGroup: vi.fn(() => Promise.resolve(null)), - setGroup: vi.fn(pending), - setGroupVolume: vi.fn(pending), - setQueueId: vi.fn(() => Promise.resolve()), - loadContent: vi.fn(pending), - setPlayModes: vi.fn(pending), - skipToTrack: vi.fn(pending), - seek: vi.fn(pending), - reorderQueue: vi.fn(pending), - removeFromQueue: vi.fn(pending), - clearQueue: vi.fn(pending), - openWsMonitor: vi.fn(() => Promise.resolve()), - openHttpMonitor: vi.fn(() => Promise.resolve()), - openDevTools: vi.fn(() => Promise.resolve()), - openMiniPlayer: vi.fn(() => Promise.resolve()), - closeMiniPlayer: vi.fn(() => Promise.resolve()), - getDisplayName: vi.fn(pending), - setDisplayName: vi.fn(pending), + getActiveGroup: vi.fn(() => Promise.resolve(null)), + setGroup: vi.fn(pending), + setGroupVolume: vi.fn(pending), + setQueueId: vi.fn(() => Promise.resolve()), + loadContent: vi.fn(pending), + setPlayModes: vi.fn(pending), + skipToTrack: vi.fn(pending), + seek: vi.fn(pending), + reorderQueue: vi.fn(pending), + removeFromQueue: vi.fn(pending), + clearQueue: vi.fn(pending), + openWsMonitor: vi.fn(() => Promise.resolve()), + openHttpMonitor: vi.fn(() => Promise.resolve()), + openDevTools: vi.fn(() => Promise.resolve()), + openMiniPlayer: vi.fn(() => Promise.resolve()), + closeMiniPlayer: vi.fn(() => Promise.resolve()), + getDisplayName: vi.fn(pending), + setDisplayName: vi.fn(pending), getQueueDockedWidth: vi.fn(() => Promise.resolve(380)), setQueueDockedWidth: vi.fn(() => Promise.resolve()), - publishQueued: vi.fn(pending), + publishQueued: vi.fn(pending), refreshAttribution: vi.fn(pending), - refreshPlayback: vi.fn(pending), - resync: vi.fn(pending), - play: vi.fn(pending), - pause: vi.fn(pending), - skipNext: vi.fn(pending), - skipPrev: vi.fn(pending), - getVersion: vi.fn(pending), - isNewVersion: vi.fn(() => Promise.resolve(false)), - openExternal: vi.fn(() => Promise.resolve()), + refreshPlayback: vi.fn(pending), + resync: vi.fn(pending), + play: vi.fn(pending), + pause: vi.fn(pending), + skipNext: vi.fn(pending), + skipPrev: vi.fn(pending), + getVersion: vi.fn(pending), + isNewVersion: vi.fn(() => Promise.resolve(false)), + openExternal: vi.fn(() => Promise.resolve()), fetchRecentlyPlayed: vi.fn(() => Promise.resolve(null)), - fetchDjSet: vi.fn(() => Promise.resolve({ tracks: [], artists: [], listeners: [], artistsConsidered: 0, inferredListeners: true })), + fetchDjSet: vi.fn(() => + Promise.resolve({ tracks: [], artists: [], listeners: [], artistsConsidered: 0, inferredListeners: true }) + ), fetchOfficePresence: vi.fn(() => Promise.resolve({ inOffice: [], basis: 'none', observed: 0, detail: [] })), - djAutoplay: vi.fn(() => Promise.resolve({ enabled: false, leaseHeld: false, enqueue: null, upcoming: [], fillerUri: null })), - geniusDescription: vi.fn(() => Promise.resolve(null)), - geniusArtist: vi.fn(() => Promise.resolve(null)), - geniusAlbumYear: vi.fn(() => Promise.resolve(null)), - trackEvent: vi.fn(() => Promise.resolve()), - minimizeWindow: vi.fn(() => Promise.resolve()), - maximizeWindow: vi.fn(() => Promise.resolve()), - closeWindow: vi.fn(() => Promise.resolve()), - isWindowMaximized: vi.fn(() => Promise.resolve(false)), - onWindowMaximized: vi.fn(() => () => {}), + djAutoplay: vi.fn(() => + Promise.resolve({ enabled: false, leaseHeld: false, enqueue: null, upcoming: [], fillerUri: null }) + ), + geniusDescription: vi.fn(() => Promise.resolve(null)), + geniusArtist: vi.fn(() => Promise.resolve(null)), + geniusAlbumYear: vi.fn(() => Promise.resolve(null)), + trackEvent: vi.fn(() => Promise.resolve()), + minimizeWindow: vi.fn(() => Promise.resolve()), + maximizeWindow: vi.fn(() => Promise.resolve()), + closeWindow: vi.fn(() => Promise.resolve()), + isWindowMaximized: vi.fn(() => Promise.resolve(false)), + onWindowMaximized: vi.fn(() => () => {}), onUpdateDownloaded: vi.fn(noop), - installUpdate: vi.fn(pending), - fetchPlaylists: vi.fn(() => Promise.resolve([])), - fetchPlaylist: vi.fn(pending), - createPlaylist: vi.fn(pending), - addTrackToPlaylist: vi.fn(pending), + installUpdate: vi.fn(pending), + fetchPlaylists: vi.fn(() => Promise.resolve([])), + fetchPlaylist: vi.fn(pending), + createPlaylist: vi.fn(pending), + addTrackToPlaylist: vi.fn(pending), removeTrackFromPlaylist: vi.fn(pending), reorderPlaylistTracks: vi.fn(pending), - ensureFavourites: vi.fn(pending), - updatePlaylist: vi.fn(pending), - deletePlaylist: vi.fn(pending), - joinPlaylist: vi.fn(pending), - uploadPlaylistImage: vi.fn(pending), - fetchUsers: vi.fn(() => Promise.resolve([])), - fetchUserProfile: vi.fn(() => Promise.resolve(null)), - uploadProfileImage: vi.fn(pending), - getEntraUser: vi.fn(() => Promise.resolve(null)), - entraSignOut: vi.fn(() => Promise.resolve()), - entraReLogin: vi.fn(() => Promise.resolve()), - onEntraReady: vi.fn(noop), - renameUser: vi.fn(() => Promise.resolve({ ok: true })), + ensureFavourites: vi.fn(pending), + updatePlaylist: vi.fn(pending), + deletePlaylist: vi.fn(pending), + joinPlaylist: vi.fn(pending), + uploadPlaylistImage: vi.fn(pending), + fetchUsers: vi.fn(() => Promise.resolve([])), + fetchUserProfile: vi.fn(() => Promise.resolve(null)), + uploadProfileImage: vi.fn(pending), + getEntraUser: vi.fn(() => Promise.resolve(null)), + entraSignOut: vi.fn(() => Promise.resolve()), + entraReLogin: vi.fn(() => Promise.resolve()), + onEntraReady: vi.fn(noop), + renameUser: vi.fn(() => Promise.resolve({ ok: true })), updateNowPlayingSmtc: vi.fn(() => Promise.resolve()), }, }); diff --git a/apps/desktop/renderer/src/types/globals.d.ts b/apps/desktop/renderer/src/types/globals.d.ts index 173549e..5b33b60 100644 --- a/apps/desktop/renderer/src/types/globals.d.ts +++ b/apps/desktop/renderer/src/types/globals.d.ts @@ -441,9 +441,9 @@ interface SonosPreload { geniusAlbumYear: (albumName: string, artistName: string) => Promise; /** Fire-and-forget telemetry event routed through the main process. No-op when App Insights is not configured. */ trackEvent: (name: string, properties?: Record) => Promise; - minimizeWindow: () => Promise; - maximizeWindow: () => Promise; - closeWindow: () => Promise; + minimizeWindow: () => Promise; + maximizeWindow: () => Promise; + closeWindow: () => Promise; isWindowMaximized: () => Promise; onWindowMaximized: (cb: (maximized: boolean) => void) => Unsubscribe; onUpdateDownloaded: (cb: (version: string) => void) => Unsubscribe; @@ -458,16 +458,30 @@ interface SonosPreload { removeTrackFromPlaylist: (playlistId: string, uri: string) => Promise; reorderPlaylistTracks: (playlistId: string, fromIndex: number, toIndex: number) => Promise; joinPlaylist: (playlistId: string, action: 'join' | 'leave') => Promise; - uploadPlaylistImage: (playlistId: string, data: ArrayBuffer, mimeType: string, userName: string) => Promise<{ imageUrl: string } | { error: string }>; + uploadPlaylistImage: ( + playlistId: string, + data: ArrayBuffer, + mimeType: string, + userName: string + ) => Promise<{ imageUrl: string } | { error: string }>; fetchUsers: (includeSelf?: boolean) => Promise; fetchUserProfile: (userName: string) => Promise; - uploadProfileImage: (userName: string, data: ArrayBuffer, mimeType: string) => Promise<{ imageUrl: string } | { error: string }>; + uploadProfileImage: ( + userName: string, + data: ArrayBuffer, + mimeType: string + ) => Promise<{ imageUrl: string } | { error: string }>; getEntraUser: () => Promise; entraSignOut: () => Promise; entraReLogin: () => Promise; onEntraReady: (cb: (user: EntraUser) => void) => Unsubscribe; renameUser: (oldName: string, newName: string) => Promise<{ ok?: boolean; error?: string }>; - updateNowPlayingSmtc: (data: { title?: string; artist?: string; album?: string; artworkUrl?: string }) => Promise; + updateNowPlayingSmtc: (data: { + title?: string; + artist?: string; + album?: string; + artworkUrl?: string; + }) => Promise; } interface Window { diff --git a/apps/desktop/renderer/src/types/provider.ts b/apps/desktop/renderer/src/types/provider.ts index ef97e68..7102e74 100644 --- a/apps/desktop/renderer/src/types/provider.ts +++ b/apps/desktop/renderer/src/types/provider.ts @@ -1,7 +1,7 @@ export type ProviderId = 'sonos'; // | 'spotify' — extend per new provider export interface NormalizedTrack { - id: string; // provider-scoped unique ID (objectId for Sonos) + id: string; // provider-scoped unique ID (objectId for Sonos) title: string; artist: string; albumName: string | null; diff --git a/apps/desktop/renderer/src/types/sonos.ts b/apps/desktop/renderer/src/types/sonos.ts index 83bdee8..9e7daad 100644 --- a/apps/desktop/renderer/src/types/sonos.ts +++ b/apps/desktop/renderer/src/types/sonos.ts @@ -1,7 +1,7 @@ // Shared TypeScript interfaces for Sonos API response shapes export interface GroupInfo { - id: string; // full groupId with session suffix + id: string; // full groupId with session suffix coordinatorId: string; // bare RINCON ID name: string; playerIds: string[]; diff --git a/apps/desktop/src/auth-entra.ts b/apps/desktop/src/auth-entra.ts index c5200ae..0cd1ff0 100644 --- a/apps/desktop/src/auth-entra.ts +++ b/apps/desktop/src/auth-entra.ts @@ -35,7 +35,9 @@ export class EntraAuth { try { const data = await fs.readFile(cacheFile, 'utf8'); ctx.tokenCache.deserialize(data); - } catch { /* no cache file yet */ } + } catch { + /* no cache file yet */ + } }, afterCacheAccess: async (ctx) => { if (ctx.cacheHasChanged) { @@ -65,7 +67,9 @@ export class EntraAuth { // MSAL-node starts a loopback server and handles the redirect automatically. const req: InteractiveRequest = { scopes: SCOPES, - openBrowser: async (url: string) => { await shell.openExternal(url); }, + openBrowser: async (url: string) => { + await shell.openExternal(url); + }, successTemplate: '

Signed in to TrueTunes!

You can close this tab and return to the app.

', errorTemplate: '

Sign-in failed

{errorMessage}

You can close this tab.

', ...(prompt ? { prompt } : {}), @@ -96,7 +100,11 @@ export class EntraAuth { for (const account of accounts) { await this.msalApp.getTokenCache().removeAccount(account); } - try { await fs.unlink(this.cacheFile); } catch { /* ignore */ } + try { + await fs.unlink(this.cacheFile); + } catch { + /* ignore */ + } } private extractUser(result: import('@azure/msal-node').AuthenticationResult | null): EntraUser { @@ -104,7 +112,8 @@ export class EntraAuth { const claims = result.idTokenClaims as Record | undefined; const oid = ((claims?.oid ?? claims?.sub) as string | undefined) ?? ''; const name = ((claims?.name ?? result.account?.name) as string | undefined) ?? ''; - const email = (result.account?.username ?? (claims?.email ?? claims?.preferred_username) as string | undefined) ?? ''; + const email = + result.account?.username ?? ((claims?.email ?? claims?.preferred_username) as string | undefined) ?? ''; if (!oid) throw new Error('Entra token missing oid claim'); return { oid, name, email }; } diff --git a/apps/desktop/src/graph-presence.test.ts b/apps/desktop/src/graph-presence.test.ts index e27718b..d3c2b0c 100644 --- a/apps/desktop/src/graph-presence.test.ts +++ b/apps/desktop/src/graph-presence.test.ts @@ -9,30 +9,40 @@ import { resolveRoster, isInOffice, type GraphPresence } from './graph-presence. * than being tidied into shapes that happen to suit the code. */ const REAL: GraphPresence[] = [ - { id: 'o-elliot', availability: 'Available', workLocation: { workLocationType: 'office', source: 'scheduled' } }, - { id: 'o-rich', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, - { id: 'o-bog', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, - { id: 'o-sam', availability: 'Available', workLocation: { workLocationType: 'remote', source: 'scheduled' } }, - { id: 'o-abbie', availability: 'Available', workLocation: { workLocationType: 'remote', source: 'scheduled' } }, - { id: 'o-alex', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, - { id: 'o-joe', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, + { id: 'o-elliot', availability: 'Available', workLocation: { workLocationType: 'office', source: 'scheduled' } }, + { id: 'o-rich', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, + { id: 'o-bog', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, + { id: 'o-sam', availability: 'Available', workLocation: { workLocationType: 'remote', source: 'scheduled' } }, + { id: 'o-abbie', availability: 'Available', workLocation: { workLocationType: 'remote', source: 'scheduled' } }, + { id: 'o-alex', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, + { id: 'o-joe', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, // Rostered into the office but never signed in — the case that matters. - { id: 'o-simrah', availability: 'Offline', workLocation: { workLocationType: 'office', source: 'scheduled' } }, - { id: 'o-steve', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, - { id: 'o-fergus', availability: 'Offline', workLocation: undefined }, - { id: 'o-longalex',availability: 'Available', workLocation: { workLocationType: 'remote', source: 'scheduled' } }, - { id: 'o-aidan', availability: 'Available', workLocation: { workLocationType: 'office', source: 'scheduled' } }, - { id: 'o-josh', availability: 'Available', workLocation: { workLocationType: 'remote', source: 'manual' } }, - { id: 'o-kade', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, + { id: 'o-simrah', availability: 'Offline', workLocation: { workLocationType: 'office', source: 'scheduled' } }, + { id: 'o-steve', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, + { id: 'o-fergus', availability: 'Offline', workLocation: undefined }, + { id: 'o-longalex', availability: 'Available', workLocation: { workLocationType: 'remote', source: 'scheduled' } }, + { id: 'o-aidan', availability: 'Available', workLocation: { workLocationType: 'office', source: 'scheduled' } }, + { id: 'o-josh', availability: 'Available', workLocation: { workLocationType: 'remote', source: 'manual' } }, + { id: 'o-kade', availability: 'Available', workLocation: { workLocationType: 'office', source: 'automatic' } }, // No Teams licence, or never signed in. Third-heaviest queuer in the office. { id: 'o-muhamad', availability: 'PresenceUnknown', workLocation: undefined }, ]; const NAMES: Record = { - 'o-elliot': 'Elliot', 'o-rich': 'Rich', 'o-bog': 'Bog', 'o-sam': 'Sam', - 'o-abbie': 'Abbie', 'o-alex': 'Alex', 'o-joe': 'Joe Pitts', 'o-simrah': 'Simrah', - 'o-steve': 'Not Steve', 'o-fergus': 'Fergus Westlake', 'o-longalex': 'Long Alex', - 'o-aidan': 'Aidan', 'o-josh': 'Josh', 'o-kade': 'Kade', + 'o-elliot': 'Elliot', + 'o-rich': 'Rich', + 'o-bog': 'Bog', + 'o-sam': 'Sam', + 'o-abbie': 'Abbie', + 'o-alex': 'Alex', + 'o-joe': 'Joe Pitts', + 'o-simrah': 'Simrah', + 'o-steve': 'Not Steve', + 'o-fergus': 'Fergus Westlake', + 'o-longalex': 'Long Alex', + 'o-aidan': 'Aidan', + 'o-josh': 'Josh', + 'o-kade': 'Kade', 'o-muhamad': 'Muhamad', }; @@ -40,10 +50,7 @@ test('builds the room from sensed presence, not availability or intent', () => { const roster = resolveRoster(REAL, NAMES); assert.equal(roster.basis, 'sensed'); // Only the six Teams actually detected on the office network. - assert.deepEqual( - roster.inOffice.sort(), - ['Alex', 'Bog', 'Joe Pitts', 'Kade', 'Not Steve', 'Rich'], - ); + assert.deepEqual(roster.inOffice.sort(), ['Alex', 'Bog', 'Joe Pitts', 'Kade', 'Not Steve', 'Rich']); }); test('rostered-but-never-detected is treated as absent', () => { @@ -59,7 +66,7 @@ test('falls back to rostered intent when nothing is being sensed', () => { const noSensing = REAL.map((p) => p.workLocation?.source === 'automatic' ? { ...p, workLocation: { ...p.workLocation, source: 'scheduled' as const } } - : p, + : p ); const roster = resolveRoster(noSensing, NAMES); assert.equal(roster.basis, 'rostered'); @@ -113,10 +120,7 @@ test('falls back to availability only when nobody sets a work location', () => { }); test('reports "none" when there is nothing to go on', () => { - const roster = resolveRoster( - [{ id: 'a', availability: 'Offline' }], - { a: 'Ann' }, - ); + const roster = resolveRoster([{ id: 'a', availability: 'Offline' }], { a: 'Ann' }); assert.equal(roster.basis, 'none'); assert.deepEqual(roster.inOffice, []); }); diff --git a/apps/desktop/src/graph-presence.ts b/apps/desktop/src/graph-presence.ts index b6eb530..e530578 100644 --- a/apps/desktop/src/graph-presence.ts +++ b/apps/desktop/src/graph-presence.ts @@ -109,10 +109,7 @@ export interface OfficeRoster { * flattened away: "at a desk" quietly masquerading as "in the office" would * include everyone working from home. */ -export function resolveRoster( - presences: GraphPresence[], - oidToName: Record, -): OfficeRoster { +export function resolveRoster(presences: GraphPresence[], oidToName: Record): OfficeRoster { const detail = presences.map((p) => ({ userId: oidToName[p.id] ?? p.id, availability: p.availability, @@ -122,23 +119,19 @@ export function resolveRoster( // 'automatic' means Teams saw them on the office network — an observation. // Everything else is someone's stated intent. - const observed = presences.filter( - (p) => isInOffice(p) && p.workLocation?.source === 'automatic', - ).length; + const observed = presences.filter((p) => isInOffice(p) && p.workLocation?.source === 'automatic').length; const names = (list: GraphPresence[]) => list.map((p) => oidToName[p.id]).filter(Boolean); // 1. Sensed. Teams saw them on the network — trust it, and trust its silence. if (observed > 0) { - const sensed = presences.filter( - (p) => isInOffice(p) && p.workLocation?.source === 'automatic', - ); + const sensed = presences.filter((p) => isInOffice(p) && p.workLocation?.source === 'automatic'); return { inOffice: names(sensed), basis: 'sensed', observed, detail }; } // 2. Rostered. No automatic detection anywhere, so fall back to stated intent. const anyWorkLocation = presences.some( - (p) => p.workLocation?.workLocationType && p.workLocation.workLocationType !== 'unspecified', + (p) => p.workLocation?.workLocationType && p.workLocation.workLocationType !== 'unspecified' ); if (anyWorkLocation) { return { inOffice: names(presences.filter(isInOffice)), basis: 'rostered', observed, detail }; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b062b33..00d3321 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,17 @@ import { config as loadEnv } from 'dotenv'; loadEnv(); // loads .env from cwd (repo root) in dev; no-op if file absent -import { app, BrowserWindow, ipcMain, nativeImage, safeStorage, session, shell, Menu, IpcMainInvokeEvent } from 'electron'; +import { + app, + BrowserWindow, + ipcMain, + nativeImage, + safeStorage, + session, + shell, + Menu, + IpcMainInvokeEvent, +} from 'electron'; import { autoUpdater } from 'electron-updater'; import { officePubSub } from './pubsub'; import { EntraAuth, PRESENCE_SCOPE } from './auth-entra'; @@ -17,7 +27,9 @@ try { for (const [k, v] of Object.entries(baked)) { if (v && !process.env[k]) process.env[k] = v; } -} catch { /* dev: dotenv already handled it */ } +} catch { + /* dev: dotenv already handled it */ +} import { randomUUID } from 'crypto'; import WebSocket from 'ws'; import type { FetchRequest, FetchResponse } from './types'; @@ -41,24 +53,33 @@ app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); // AppUserModelID we set with sessions created before the first window). // Button presses mirror the existing Windows taskbar thumbar handlers (see // setThumbar) — both drive the same WS playback commands directly. -smtc.init((action) => { - const groupId = config.groupId; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - switch (action) { - case 'play': - wsSend({ namespace: 'playback', groupId, command: 'play' }, { allowTvPauseRestore: true, deviceFeedback: 'NONE' }).catch(() => {}); - break; - case 'pause': - wsSend({ namespace: 'playback', groupId, command: 'pause' }, { allowTvPauseRestore: true, deviceFeedback: 'NONE' }).catch(() => {}); - break; - case 'next': - wsSend({ namespace: 'playback', groupId, command: 'skipToNextTrack' }, {}).catch(() => {}); - break; - case 'previous': - wsSend({ namespace: 'playback', groupId, command: 'skipBack' }, {}).catch(() => {}); - break; - } -}, { displayName: 'True Tunes', appPath: app.getAppPath(), isPackaged: app.isPackaged }); +smtc.init( + (action) => { + const groupId = config.groupId; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + switch (action) { + case 'play': + wsSend( + { namespace: 'playback', groupId, command: 'play' }, + { allowTvPauseRestore: true, deviceFeedback: 'NONE' } + ).catch(() => {}); + break; + case 'pause': + wsSend( + { namespace: 'playback', groupId, command: 'pause' }, + { allowTvPauseRestore: true, deviceFeedback: 'NONE' } + ).catch(() => {}); + break; + case 'next': + wsSend({ namespace: 'playback', groupId, command: 'skipToNextTrack' }, {}).catch(() => {}); + break; + case 'previous': + wsSend({ namespace: 'playback', groupId, command: 'skipBack' }, {}).catch(() => {}); + break; + } + }, + { displayName: 'True Tunes', appPath: app.getAppPath(), isPackaged: app.isPackaged } +); // ─── Config ────────────────────────────────────────────────────────────────── @@ -120,7 +141,8 @@ async function loadConfig(): Promise { if (typeof parsed.entraName === 'string') config.entraName = parsed.entraName; if (typeof parsed.entraEmail === 'string') config.entraEmail = parsed.entraEmail; if (typeof parsed.lastSeenVersion === 'string') config.lastSeenVersion = parsed.lastSeenVersion; - if (typeof parsed.preferredCoordinatorId === 'string') config.preferredCoordinatorId = parsed.preferredCoordinatorId; + if (typeof parsed.preferredCoordinatorId === 'string') + config.preferredCoordinatorId = parsed.preferredCoordinatorId; if (typeof parsed.queueDockedWidth === 'number') config.queueDockedWidth = parsed.queueDockedWidth; } catch { // No config file yet — use defaults @@ -466,8 +488,7 @@ async function sonosFetch(request: FetchRequest): Promise { } const isTransient = - networkError !== null || - (attemptResponse !== null && TRANSIENT_STATUSES.has(attemptResponse.status)); + networkError !== null || (attemptResponse !== null && TRANSIENT_STATUSES.has(attemptResponse.status)); if (isTransient && isIdempotent && attempt < RETRY_DELAYS_MS.length) { telemetry.event('api_retry', { @@ -546,7 +567,7 @@ function wsSend(header: Record, payload: Record { // re-broadcast it here (same pattern as playbackExtended above). const activeVol = volSubResults.find((r) => r?.groupId === config.groupId); if (activeVol?.resp) { - broadcastToRenderers('ws:message', [ - { namespace: 'groupVolume', groupId: activeVol.groupId }, - activeVol.resp, - ]); + broadcastToRenderers('ws:message', [{ namespace: 'groupVolume', groupId: activeVol.groupId }, activeVol.resp]); } log('[ws] Bootstrap complete'); @@ -934,7 +952,7 @@ async function connectWebSocket(): Promise { .then(() => { telemetry.event('ws_connected', { bootstrapMs: Date.now() - bootstrapStart, - groupCount: discoveredGroups.length, + groupCount: discoveredGroups.length, }); if (config.groupId) telemetry.setContext({ groupId: config.groupId }); }) @@ -997,7 +1015,10 @@ function setThumbar(win: BrowserWindow, isPlaying: boolean): void { const groupId = config.groupId; if (!ws || ws.readyState !== WebSocket.OPEN) return; const cmd = thumbarIsPlaying ? 'pause' : 'play'; - wsSend({ namespace: 'playback', groupId, command: cmd }, { allowTvPauseRestore: true, deviceFeedback: 'NONE' }).catch(() => {}); + wsSend( + { namespace: 'playback', groupId, command: cmd }, + { allowTvPauseRestore: true, deviceFeedback: 'NONE' } + ).catch(() => {}); }, }, { @@ -1191,7 +1212,7 @@ async function lookupProfileByOid(oid: string): Promise { const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/profile/lookup?oid=${encodeURIComponent(oid)}`); if (res.status === 404) return null; if (!res.ok) return null; - const data = await res.json() as { displayName?: string }; + const data = (await res.json()) as { displayName?: string }; return data.displayName ?? null; } catch { return null; @@ -1286,7 +1307,7 @@ function createUIWindow(): void { }, }); - uiWin.on('maximize', () => uiWin?.webContents.send('win:maximized', true)); + uiWin.on('maximize', () => uiWin?.webContents.send('win:maximized', true)); uiWin.on('unmaximize', () => uiWin?.webContents.send('win:maximized', false)); setThumbar(uiWin, thumbarIsPlaying); @@ -1394,42 +1415,57 @@ ipcMain.handle('playback:pause', (_event: IpcMainInvokeEvent) => { ); }); -ipcMain.handle('smtc:update', (_event: IpcMainInvokeEvent, data: { title?: string; artist?: string; album?: string; artworkUrl?: string }) => { - smtc.setMetadata({ - title: data.title, - artist: data.artist, - album: data.album, - thumbnailUrl: data.artworkUrl, - }); -}); +ipcMain.handle( + 'smtc:update', + (_event: IpcMainInvokeEvent, data: { title?: string; artist?: string; album?: string; artworkUrl?: string }) => { + smtc.setMetadata({ + title: data.title, + artist: data.artist, + album: data.album, + thumbnailUrl: data.artworkUrl, + }); + } +); ipcMain.handle('debug:openWsMonitor', () => openDebugWindow()); ipcMain.handle('debug:openHttpMonitor', () => openHttpDebugWindow()); -ipcMain.handle('debug:openDevTools', () => { if (!app.isPackaged) uiWin?.webContents.openDevTools(); }); +ipcMain.handle('debug:openDevTools', () => { + if (!app.isPackaged) uiWin?.webContents.openDevTools(); +}); ipcMain.handle('app:version', () => app.getVersion()); ipcMain.handle('app:isNewVersion', () => isNewVersion); ipcMain.handle('app:openExternal', (_: IpcMainInvokeEvent, url: string) => shell.openExternal(url)); -ipcMain.handle('win:minimize', () => { uiWin?.minimize(); }); -ipcMain.handle('win:maximize', () => { if (uiWin?.isMaximized()) uiWin.unmaximize(); else uiWin?.maximize(); }); -ipcMain.handle('win:close', () => { uiWin?.close(); }); +ipcMain.handle('win:minimize', () => { + uiWin?.minimize(); +}); +ipcMain.handle('win:maximize', () => { + if (uiWin?.isMaximized()) uiWin.unmaximize(); + else uiWin?.maximize(); +}); +ipcMain.handle('win:close', () => { + uiWin?.close(); +}); ipcMain.handle('win:is-maximized', () => uiWin?.isMaximized() ?? false); ipcMain.handle('config:getDisplayName', () => config.displayName ?? null); -ipcMain.handle('config:setDisplayName', async (_: IpcMainInvokeEvent, name: string): Promise<{ error: string } | null> => { - // Guard empty name (sign-out path) — skip claim check. - if (name && config.entraOid) { - const taken = await claimDisplayName(config.entraOid, name); - if (taken) return { error: 'taken' }; +ipcMain.handle( + 'config:setDisplayName', + async (_: IpcMainInvokeEvent, name: string): Promise<{ error: string } | null> => { + // Guard empty name (sign-out path) — skip claim check. + if (name && config.entraOid) { + const taken = await claimDisplayName(config.entraOid, name); + if (taken) return { error: 'taken' }; + } + config.displayName = name; + telemetry.setContext({ userId: name }); + await saveConfig(); + initPubSub().catch(console.error); + return null; } - config.displayName = name; - telemetry.setContext({ userId: name }); - await saveConfig(); - initPubSub().catch(console.error); - return null; -}); +); ipcMain.handle('auth:getEntraUser', (): EntraUser | null => { if (!config.entraOid) return null; @@ -1489,12 +1525,12 @@ ipcMain.handle('playlist:create', (_: IpcMainInvokeEvent, name: string, isPublic method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, isPublic, owner: config.displayName }), - }), + }) ); async function playlistFetch(url: string, init: RequestInit): Promise { const res = await fetch(url, init); - const body = await res.json().catch(() => ({})) as { error?: string }; + const body = (await res.json().catch(() => ({}))) as { error?: string }; if (!res.ok) throw new Error(body.error ?? `Server error ${res.status}`); return body; } @@ -1504,7 +1540,7 @@ ipcMain.handle('playlist:addTrack', (_: IpcMainInvokeEvent, playlistId: string, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track, userName: config.displayName }), - }), + }) ); ipcMain.handle('playlist:join', (_: IpcMainInvokeEvent, playlistId: string, action: 'join' | 'leave') => @@ -1512,7 +1548,7 @@ ipcMain.handle('playlist:join', (_: IpcMainInvokeEvent, playlistId: string, acti method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userName: config.displayName, action }), - }), + }) ); ipcMain.handle('profile:ensureFavourites', async () => { @@ -1528,15 +1564,17 @@ ipcMain.handle('playlist:delete', (_: IpcMainInvokeEvent, playlistId: string) => method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userName: config.displayName ?? '' }), - }), + }) ); -ipcMain.handle('playlist:update', (_: IpcMainInvokeEvent, playlistId: string, patch: { name?: string; isPublic?: boolean }) => - playlistFetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userName: config.displayName, ...patch }), - }), +ipcMain.handle( + 'playlist:update', + (_: IpcMainInvokeEvent, playlistId: string, patch: { name?: string; isPublic?: boolean }) => + playlistFetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userName: config.displayName, ...patch }), + }) ); ipcMain.handle('playlist:removeTrack', (_: IpcMainInvokeEvent, playlistId: string, uri: string) => @@ -1544,30 +1582,35 @@ ipcMain.handle('playlist:removeTrack', (_: IpcMainInvokeEvent, playlistId: strin method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userName: config.displayName, uri }), - }), + }) ); -ipcMain.handle('playlist:reorderTracks', (_: IpcMainInvokeEvent, playlistId: string, fromIndex: number, toIndex: number) => - playlistFetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}/tracks`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userName: config.displayName, fromIndex, toIndex }), - }), +ipcMain.handle( + 'playlist:reorderTracks', + (_: IpcMainInvokeEvent, playlistId: string, fromIndex: number, toIndex: number) => + playlistFetch(`${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}/tracks`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userName: config.displayName, fromIndex, toIndex }), + }) ); -ipcMain.handle('playlist:uploadImage', async (_: IpcMainInvokeEvent, playlistId: string, data: ArrayBuffer, mimeType: string, userName: string) => { - try { - const url = `${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}/image?userName=${encodeURIComponent(userName)}`; - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': mimeType }, - body: Buffer.from(data), - }); - return await res.json(); - } catch (err) { - return { error: String(err) }; +ipcMain.handle( + 'playlist:uploadImage', + async (_: IpcMainInvokeEvent, playlistId: string, data: ArrayBuffer, mimeType: string, userName: string) => { + try { + const url = `${PUBSUB_FUNCTION_URL}/api/playlist/${encodeURIComponent(playlistId)}/image?userName=${encodeURIComponent(userName)}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': mimeType }, + body: Buffer.from(data), + }); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } } -}); +); ipcMain.handle('users:list', async (_: IpcMainInvokeEvent, includeSelf?: boolean) => { // HomePanel wants "other people", so excluding yourself is the default. The @@ -1576,7 +1619,7 @@ ipcMain.handle('users:list', async (_: IpcMainInvokeEvent, includeSelf?: boolean const params = exclude ? `?exclude=${encodeURIComponent(exclude)}` : ''; try { const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/users${params}`); - const body = await res.json() as unknown; + const body = (await res.json()) as unknown; return Array.isArray(body) ? body : []; } catch { return []; @@ -1592,43 +1635,61 @@ ipcMain.handle('profile:get', async (_: IpcMainInvokeEvent, userName: string) => } }); -ipcMain.handle('profile:uploadImage', async (_: IpcMainInvokeEvent, userName: string, data: ArrayBuffer, mimeType: string) => { - try { - const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/profile/${encodeURIComponent(userName)}/image`, { - method: 'POST', - headers: { 'Content-Type': mimeType }, - body: Buffer.from(data), - }); - return await res.json(); - } catch (err) { - return { error: String(err) }; +ipcMain.handle( + 'profile:uploadImage', + async (_: IpcMainInvokeEvent, userName: string, data: ArrayBuffer, mimeType: string) => { + try { + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/profile/${encodeURIComponent(userName)}/image`, { + method: 'POST', + headers: { 'Content-Type': mimeType }, + body: Buffer.from(data), + }); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } } -}); +); -ipcMain.handle('profile:rename', async (_: IpcMainInvokeEvent, oldName: string, newName: string): Promise<{ ok?: boolean; error?: string }> => { - if (!config.entraOid) return { error: 'not-authed' }; - try { - const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/profile/rename`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ oid: config.entraOid, oldName, newName }), - }); - if (res.status === 409) return { error: 'taken' }; - if (res.status === 403) return { error: 'not-owner' }; - if (!res.ok) return { error: `server error ${res.status}` }; - config.displayName = newName; - telemetry.setContext({ userId: newName }); - await saveConfig(); - initPubSub().catch(console.error); - return { ok: true }; - } catch (err) { - return { error: String(err) }; +ipcMain.handle( + 'profile:rename', + async (_: IpcMainInvokeEvent, oldName: string, newName: string): Promise<{ ok?: boolean; error?: string }> => { + if (!config.entraOid) return { error: 'not-authed' }; + try { + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/profile/rename`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oid: config.entraOid, oldName, newName }), + }); + if (res.status === 409) return { error: 'taken' }; + if (res.status === 403) return { error: 'not-owner' }; + if (!res.ok) return { error: `server error ${res.status}` }; + config.displayName = newName; + telemetry.setContext({ userId: newName }); + await saveConfig(); + initPubSub().catch(console.error); + return { ok: true }; + } catch (err) { + return { error: String(err) }; + } } -}); +); ipcMain.handle( 'pubsub:publishQueued', - async (_: IpcMainInvokeEvent, item: { eventType: 'track' | 'album'; uri: string; trackName: string; artist: string; artistId?: string; album?: string; albumId?: string; imageUrl?: string }) => { + async ( + _: IpcMainInvokeEvent, + item: { + eventType: 'track' | 'album'; + uri: string; + trackName: string; + artist: string; + artistId?: string; + album?: string; + albumId?: string; + imageUrl?: string; + } + ) => { await officePubSub.publishQueued(item).catch(() => { /* silent */ }); @@ -1655,7 +1716,7 @@ ipcMain.handle('stats:fetch', async (_: IpcMainInvokeEvent, period: string, user try { let url = `${PUBSUB_FUNCTION_URL}/api/stats?period=${encodeURIComponent(period)}`; if (userId) url += `&userId=${encodeURIComponent(userId)}`; - if (count) url += `&count=${count}`; + if (count) url += `&count=${count}`; const res = await fetch(url); return await res.json(); } catch (err) { @@ -1700,21 +1761,21 @@ ipcMain.handle('presence:office', async () => { } }); -ipcMain.handle('dj:fetch', async ( - _: IpcMainInvokeEvent, - opts: { users?: string[]; limit?: number; excludeUris?: string[] } = {}, -) => { - try { - const params = new URLSearchParams(); - if (opts.users?.length) params.set('users', opts.users.join(',')); - if (opts.limit) params.set('limit', String(opts.limit)); - if (opts.excludeUris?.length) params.set('exclude', opts.excludeUris.join(',')); - const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/dj?${params}`); - return await res.json(); - } catch (err) { - return { error: String(err) }; +ipcMain.handle( + 'dj:fetch', + async (_: IpcMainInvokeEvent, opts: { users?: string[]; limit?: number; excludeUris?: string[] } = {}) => { + try { + const params = new URLSearchParams(); + if (opts.users?.length) params.set('users', opts.users.join(',')); + if (opts.limit) params.set('limit', String(opts.limit)); + if (opts.excludeUris?.length) params.set('exclude', opts.excludeUris.join(',')); + const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/dj?${params}`); + return await res.json(); + } catch (err) { + return { error: String(err) }; + } } -}); +); ipcMain.handle('history:recent', async (_: IpcMainInvokeEvent, userId: string) => { try { @@ -1726,7 +1787,6 @@ ipcMain.handle('history:recent', async (_: IpcMainInvokeEvent, userId: string) = } }); - ipcMain.handle('game:fetch', async (_: IpcMainInvokeEvent, date?: string) => { try { const d = date && date.length ? date : 'today'; @@ -1749,7 +1809,7 @@ ipcMain.handle( gameId: string; userName: string; guesses: { main: Array<'left' | 'right'>; bonus: string[] }; - }, + } ) => { try { const res = await fetch(`${PUBSUB_FUNCTION_URL}/api/score`, { @@ -1764,7 +1824,7 @@ ipcMain.handle( } catch (err) { return { error: String(err) }; } - }, + } ); ipcMain.handle('game:leaderboard', async (_: IpcMainInvokeEvent, date?: string) => { @@ -2118,13 +2178,28 @@ ipcMain.handle('genius:description', async (_: IpcMainInvokeEvent, trackName: st } const debugReq = (id: string, url: string, ts: number) => - httpDebugWin?.webContents.send('http:req', { id, ts, operationId: 'genius', method: 'GET', url, headers: { Authorization: 'Bearer ***' } }); + httpDebugWin?.webContents.send('http:req', { + id, + ts, + operationId: 'genius', + method: 'GET', + url, + headers: { Authorization: 'Bearer ***' }, + }); const debugRes = (id: string, status: number, body: string, ts: number) => - httpDebugWin?.webContents.send('http:res', { id, status, statusText: String(status), headers: {}, body, durationMs: Date.now() - ts }); + httpDebugWin?.webContents.send('http:res', { + id, + status, + statusText: String(status), + headers: {}, + body, + durationMs: Date.now() - ts, + }); try { const searchUrl = `https://api.genius.com/search?q=${encodeURIComponent(`${trackName} ${artistName}`)}`; - const searchId = randomUUID(); const searchTs = Date.now(); + const searchId = randomUUID(); + const searchTs = Date.now(); debugReq(searchId, searchUrl, searchTs); const searchRes = await fetch(searchUrl, { headers: { Authorization: `Bearer ${key}` } }); const searchBody = await searchRes.text(); @@ -2135,7 +2210,8 @@ ipcMain.handle('genius:description', async (_: IpcMainInvokeEvent, trackName: st if (!songId) return null; const songUrl = `https://api.genius.com/songs/${songId}`; - const songId2 = randomUUID(); const songTs = Date.now(); + const songId2 = randomUUID(); + const songTs = Date.now(); debugReq(songId2, songUrl, songTs); const songRes = await fetch(songUrl, { headers: { Authorization: `Bearer ${key}` } }); const songBody = await songRes.text(); @@ -2157,14 +2233,29 @@ ipcMain.handle('genius:artist', async (_: IpcMainInvokeEvent, artistName: string } const debugReq = (id: string, url: string, ts: number) => - httpDebugWin?.webContents.send('http:req', { id, ts, operationId: 'genius:artist', method: 'GET', url, headers: { Authorization: 'Bearer ***' } }); + httpDebugWin?.webContents.send('http:req', { + id, + ts, + operationId: 'genius:artist', + method: 'GET', + url, + headers: { Authorization: 'Bearer ***' }, + }); const debugRes = (id: string, status: number, body: string, ts: number) => - httpDebugWin?.webContents.send('http:res', { id, status, statusText: String(status), headers: {}, body, durationMs: Date.now() - ts }); + httpDebugWin?.webContents.send('http:res', { + id, + status, + statusText: String(status), + headers: {}, + body, + durationMs: Date.now() - ts, + }); try { const q = trackHint ? `${trackHint} ${artistName}` : artistName; const searchUrl = `https://api.genius.com/search?q=${encodeURIComponent(q)}`; - const searchId = randomUUID(); const searchTs = Date.now(); + const searchId = randomUUID(); + const searchTs = Date.now(); debugReq(searchId, searchUrl, searchTs); const searchRes = await fetch(searchUrl, { headers: { Authorization: `Bearer ${key}` } }); const searchBody = await searchRes.text(); @@ -2180,7 +2271,8 @@ ipcMain.handle('genius:artist', async (_: IpcMainInvokeEvent, artistName: string if (!artistId) return null; const artistUrl = `https://api.genius.com/artists/${artistId}`; - const artistReqId = randomUUID(); const artistTs = Date.now(); + const artistReqId = randomUUID(); + const artistTs = Date.now(); debugReq(artistReqId, artistUrl, artistTs); const artistRes = await fetch(artistUrl, { headers: { Authorization: `Bearer ${key}` } }); const artistBody = await artistRes.text(); @@ -2225,13 +2317,28 @@ ipcMain.handle('genius:albumYear', async (_: IpcMainInvokeEvent, albumName: stri } const debugReq = (id: string, url: string, ts: number) => - httpDebugWin?.webContents.send('http:req', { id, ts, operationId: 'genius:albumYear', method: 'GET', url, headers: { Authorization: 'Bearer ***' } }); + httpDebugWin?.webContents.send('http:req', { + id, + ts, + operationId: 'genius:albumYear', + method: 'GET', + url, + headers: { Authorization: 'Bearer ***' }, + }); const debugRes = (id: string, status: number, body: string, ts: number) => - httpDebugWin?.webContents.send('http:res', { id, status, statusText: String(status), headers: {}, body, durationMs: Date.now() - ts }); + httpDebugWin?.webContents.send('http:res', { + id, + status, + statusText: String(status), + headers: {}, + body, + durationMs: Date.now() - ts, + }); try { const searchUrl = `https://api.genius.com/search?q=${encodeURIComponent(`${albumName} ${artistName}`)}`; - const searchId = randomUUID(); const searchTs = Date.now(); + const searchId = randomUUID(); + const searchTs = Date.now(); debugReq(searchId, searchUrl, searchTs); const searchRes = await fetch(searchUrl, { headers: { Authorization: `Bearer ${key}` } }); const searchBody = await searchRes.text(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 89f51b7..04405bd 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -43,7 +43,18 @@ export interface SonosAPI { setDisplayName: (name: string) => Promise<{ error: string } | null>; getQueueDockedWidth: () => Promise; setQueueDockedWidth: (width: number) => Promise; - publishQueued: (item: { eventType: 'track' | 'album'; uri: string; trackName: string; artist: string; serviceId?: string; accountId?: string; artistId?: string; album?: string; albumId?: string; imageUrl?: string }) => Promise; + publishQueued: (item: { + eventType: 'track' | 'album'; + uri: string; + trackName: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + album?: string; + albumId?: string; + imageUrl?: string; + }) => Promise; onAttributionMap: (cb: (map: AttributionMap) => void) => Unsubscribe; onAttributionEvent: (cb: (event: AttributionEvent) => void) => Unsubscribe; refreshAttribution: () => Promise; @@ -85,10 +96,15 @@ export interface SonosAPI { entraReLogin: () => Promise; onEntraReady: (cb: (user: EntraUser) => void) => Unsubscribe; renameUser: (oldName: string, newName: string) => Promise<{ ok?: boolean; error?: string }>; - updateNowPlayingSmtc: (data: { title?: string; artist?: string; album?: string; artworkUrl?: string }) => Promise; - minimizeWindow: () => Promise; - maximizeWindow: () => Promise; - closeWindow: () => Promise; + updateNowPlayingSmtc: (data: { + title?: string; + artist?: string; + album?: string; + artworkUrl?: string; + }) => Promise; + minimizeWindow: () => Promise; + maximizeWindow: () => Promise; + closeWindow: () => Promise; isWindowMaximized: () => Promise; onWindowMaximized: (cb: (maximized: boolean) => void) => Unsubscribe; onUpdateDownloaded: (cb: (version: string) => void) => Unsubscribe; @@ -191,7 +207,8 @@ contextBridge.exposeInMainWorld('sonos', { return () => ipcRenderer.removeListener('attribution:map', listener); }, refreshAttribution: () => ipcRenderer.invoke('attribution:refresh'), - fetchStats: (period: string, userId?: string, count?: number) => ipcRenderer.invoke('stats:fetch', period, userId, count), + fetchStats: (period: string, userId?: string, count?: number) => + ipcRenderer.invoke('stats:fetch', period, userId, count), fetchDailyGame: (date?: string) => ipcRenderer.invoke('game:fetch', date), submitGameScore: (input) => ipcRenderer.invoke('game:submit', input), fetchGameLeaderboard: (date?: string) => ipcRenderer.invoke('game:leaderboard', date), @@ -203,23 +220,21 @@ contextBridge.exposeInMainWorld('sonos', { ipcRenderer.on('attribution:event', listener); return () => ipcRenderer.removeListener('attribution:event', listener); }, - fetchRecentlyPlayed: (userId: string) => - ipcRenderer.invoke('history:recent', userId), + fetchRecentlyPlayed: (userId: string) => ipcRenderer.invoke('history:recent', userId), fetchDjSet: (opts?: { users?: string[]; limit?: number; excludeUris?: string[] }) => ipcRenderer.invoke('dj:fetch', opts ?? {}), fetchOfficePresence: () => ipcRenderer.invoke('presence:office'), djAutoplay: (body: unknown) => ipcRenderer.invoke('dj:autoplay', body), geniusDescription: (trackName: string, artistName: string) => ipcRenderer.invoke('genius:description', trackName, artistName), - geniusArtist: (artistName: string, trackHint?: string) => - ipcRenderer.invoke('genius:artist', artistName, trackHint), + geniusArtist: (artistName: string, trackHint?: string) => ipcRenderer.invoke('genius:artist', artistName, trackHint), geniusAlbumYear: (albumName: string, artistName: string) => ipcRenderer.invoke('genius:albumYear', albumName, artistName), trackEvent: (name: string, properties?: Record) => ipcRenderer.invoke('telemetry:event', name, properties), - minimizeWindow: () => ipcRenderer.invoke('win:minimize'), - maximizeWindow: () => ipcRenderer.invoke('win:maximize'), - closeWindow: () => ipcRenderer.invoke('win:close'), + minimizeWindow: () => ipcRenderer.invoke('win:minimize'), + maximizeWindow: () => ipcRenderer.invoke('win:maximize'), + closeWindow: () => ipcRenderer.invoke('win:close'), isWindowMaximized: () => ipcRenderer.invoke('win:is-maximized'), onWindowMaximized: (cb: (maximized: boolean) => void): Unsubscribe => { const listener = (_e: unknown, maximized: boolean) => cb(maximized); @@ -239,9 +254,11 @@ contextBridge.exposeInMainWorld('sonos', { deletePlaylist: (playlistId) => ipcRenderer.invoke('playlist:delete', playlistId), addTrackToPlaylist: (playlistId, track) => ipcRenderer.invoke('playlist:addTrack', playlistId, track), removeTrackFromPlaylist: (playlistId, uri) => ipcRenderer.invoke('playlist:removeTrack', playlistId, uri), - reorderPlaylistTracks: (playlistId, fromIndex, toIndex) => ipcRenderer.invoke('playlist:reorderTracks', playlistId, fromIndex, toIndex), + reorderPlaylistTracks: (playlistId, fromIndex, toIndex) => + ipcRenderer.invoke('playlist:reorderTracks', playlistId, fromIndex, toIndex), joinPlaylist: (playlistId, action) => ipcRenderer.invoke('playlist:join', playlistId, action), - uploadPlaylistImage: (playlistId, data, mimeType, userName) => ipcRenderer.invoke('playlist:uploadImage', playlistId, data, mimeType, userName), + uploadPlaylistImage: (playlistId, data, mimeType, userName) => + ipcRenderer.invoke('playlist:uploadImage', playlistId, data, mimeType, userName), ensureFavourites: () => ipcRenderer.invoke('profile:ensureFavourites'), fetchUsers: (includeSelf?: boolean) => ipcRenderer.invoke('users:list', includeSelf), fetchUserProfile: (userName) => ipcRenderer.invoke('profile:get', userName), diff --git a/apps/desktop/src/pubsub.ts b/apps/desktop/src/pubsub.ts index 8ffc028..3d3f7d2 100644 --- a/apps/desktop/src/pubsub.ts +++ b/apps/desktop/src/pubsub.ts @@ -19,8 +19,8 @@ import * as http from 'http'; import { URL } from 'url'; import type { AttributionMap, AttributionEvent } from './types'; -const HUB = 'office'; -const SUBPROTOCOL = 'json.webpubsub.azure.v1'; +const HUB = 'office'; +const SUBPROTOCOL = 'json.webpubsub.azure.v1'; // ─── HTTP helpers (avoid depending on global fetch in main process) ─────────── @@ -29,7 +29,10 @@ function httpGet(rawUrl: string): Promise { const url = new URL(rawUrl); const mod = url.protocol === 'https:' ? https : http; const req = mod.get(rawUrl, (res) => { - if (res.statusCode === 404) { resolve(Buffer.alloc(0)); return; } + if (res.statusCode === 404) { + resolve(Buffer.alloc(0)); + return; + } if ((res.statusCode ?? 0) >= 400) { reject(new Error(`GET ${rawUrl} → ${res.statusCode}`)); return; @@ -57,7 +60,7 @@ function httpPostJson(rawUrl: string, payload: unknown): Promise { } else { resolve(); } - }, + } ); req.on('error', reject); req.write(buf); @@ -114,7 +117,18 @@ export class OfficePubSub { } /** Publish a "track queued" event. Updates local map and broadcasts via WS. */ - async publishQueued(item: { eventType: 'track' | 'album'; uri: string; trackName: string; artist: string; serviceId?: string; accountId?: string; artistId?: string; album?: string; albumId?: string; imageUrl?: string }): Promise { + async publishQueued(item: { + eventType: 'track' | 'album'; + uri: string; + trackName: string; + artist: string; + serviceId?: string; + accountId?: string; + artistId?: string; + album?: string; + albumId?: string; + imageUrl?: string; + }): Promise { if (!item.uri) return; const event: AttributionEvent = { @@ -228,7 +242,9 @@ export class OfficePubSub { this.eventCbs.forEach((cb) => cb(event)); } } - } catch { /* ignore malformed messages */ } + } catch { + /* ignore malformed messages */ + } }); this.ws.on('error', (err) => { diff --git a/apps/desktop/src/telemetry.ts b/apps/desktop/src/telemetry.ts index 6ac269f..fc456c5 100644 --- a/apps/desktop/src/telemetry.ts +++ b/apps/desktop/src/telemetry.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'crypto'; type StringProps = Record; -type AnyProps = Record; +type AnyProps = Record; const SESSION_ID = randomUUID(); @@ -25,19 +25,19 @@ export function init(connectionString: string, appVersion: string): void { const appInsights = require('applicationinsights') as typeof import('applicationinsights'); appInsights .setup(connectionString) - .setAutoCollectRequests(false) // no HTTP server in Electron + .setAutoCollectRequests(false) // no HTTP server in Electron .setAutoCollectPerformance(false, false) - .setAutoCollectExceptions(true) // catch process-level uncaught errors + .setAutoCollectExceptions(true) // catch process-level uncaught errors .setAutoCollectDependencies(false) .setAutoCollectConsole(false) - .setUseDiskRetryCaching(true) // buffer events if network is down + .setUseDiskRetryCaching(true) // buffer events if network is down .start(); _client = appInsights.defaultClient; _client.commonProperties = { appVersion, sessionId: SESSION_ID, - platform: process.platform, + platform: process.platform, }; console.log('[telemetry] Initialized, session:', SESSION_ID); @@ -62,7 +62,9 @@ export function event(name: string, props?: AnyProps): void { for (const [k, v] of Object.entries(props ?? {})) strProps[k] = String(v); try { _client.trackEvent({ name, properties: { ..._ctx, ...strProps } }); - } catch { /* ignore — telemetry must never crash the app */ } + } catch { + /* ignore — telemetry must never crash the app */ + } } /** Track an exception. Pass any value; non-Error values are wrapped automatically. */ @@ -71,7 +73,9 @@ export function exception(err: unknown, props?: StringProps): void { const error = err instanceof Error ? err : new Error(String(err)); try { _client.trackException({ exception: error, properties: { ..._ctx, ...props } }); - } catch { /* ignore */ } + } catch { + /* ignore */ + } } /** @@ -80,11 +84,17 @@ export function exception(err: unknown, props?: StringProps): void { */ export function flush(): Promise { return new Promise((resolve) => { - if (!_client) { resolve(); return; } + if (!_client) { + resolve(); + return; + } const timer = setTimeout(resolve, 3000); try { _client.flush({ - callback: () => { clearTimeout(timer); resolve(); }, + callback: () => { + clearTimeout(timer); + resolve(); + }, }); } catch { clearTimeout(timer); From 8a8ed65b50ba30bcf17ef8f2e0af29b1edce5041 Mon Sep 17 00:00:00 2001 From: Joe Pitts Date: Fri, 9 Oct 2026 11:36:47 +0100 Subject: [PATCH 21/24] docs: describe the workspace layout and where each command runs Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UMj5XGHLroJb9q68jXkw8B --- CLAUDE.md | 35 ++++++++++++++++++++++++----------- README.md | 23 +++++++++++++++-------- apps/mobile/CLAUDE.md | 12 ++++++++---- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2fe982a..35344a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,23 +2,34 @@ ## Repository layout -This repo is **public** and holds two apps: - -- The **desktop** Electron app (everything at the root except `apps/` — described below). -- The **Android/iOS** React Native app in `apps/mobile` (moved in from `TrueNorthIT/TrueTunes-Android` with its - history). It has its own `package.json`, lockfile, lint and tests; read `apps/mobile/CLAUDE.md` before touching it. - The root ESLint and Prettier configs ignore `apps/`. +This repo is **public** and is an **npm-workspaces monorepo** (`apps/*`, `packages/*`): + +| Path | What | +| -------------- | ------------------------------------------------------------------------------------------------------- | +| `apps/desktop` | The desktop Electron app — the rest of this file. **Paths below are relative to `apps/desktop`** | +| `apps/mobile` | The Android/iOS React Native app (moved in from `TrueNorthIT/TrueTunes-Android` with its history). Read `apps/mobile/CLAUDE.md` before touching it | +| `server/` | Azure Functions backend. **Not a workspace**: own `package.json` and lockfile, because its deploy uploads `server/node_modules` | + +- One `npm install` at the root installs both apps; there is one root `package-lock.json`. Never add a lockfile under + `apps/`. +- The root `overrides` pin `react`/`react-dom` to the version Expo SDK 57 requires, plus `@tanstack/react-query` and + `@types/react`, so each has exactly one copy. Change them together with the Expo SDK. +- Each app has its own ESLint config and scripts. At the root, `npm run lint` / `typecheck` / `test:run` run every + workspace; `npm run lint:server` lints `server/` with the desktop ESLint config. +- `electron` is pinned to an exact version in `apps/desktop/package.json`: electron-builder cannot see the hoisted + `electron` package and falls back to the declared version. Bump it deliberately. +- The `.env` stays at the repo root (the server deploy reads it); the desktop `build:main` reads it from there. Workflows (`.github/workflows/`): | File | What | | -------------------- | ----------------------------------------------------------------------------------------------------- | -| `desktop.yml` | Desktop PR validation; on push to `main`, bumps the patch version and publishes a desktop release | +| `desktop.yml` | Desktop PR validation; on push to `main`, bumps `apps/desktop`'s patch version and publishes a release | | `mobile-ci.yml` | Mobile lint, typecheck, expo-doctor and tests, for changes under `apps/mobile` | | `mobile-release.yml` | On an `android-vX.Y.Z` tag, builds and signs the APK and creates a release with `--latest=false` | -- `desktop.yml` ignores `apps/mobile/**` and the mobile workflows. **Keep it that way**: otherwise every mobile commit to - `main` publishes a desktop release. +- `desktop.yml` only runs for `apps/desktop/**`, `packages/**` and the root package files (PRs also for `server/**`). + **Keep it that way**: otherwise every mobile commit to `main` publishes a desktop release. - Tags: `vX.Y.Z` is desktop, `android-vX.Y.Z` is mobile. Desktop auto-update (electron-updater, GitHub provider) reads `/releases/latest`, so a mobile release must never be marked Latest. @@ -36,6 +47,8 @@ The renderer never imports from `src/` and vice versa. Communication is strictly ## Running and building +Run these from `apps/desktop` (or add `-w apps/desktop` at the root): + ```bash npm run dev # build main + start Vite + Electron concurrently npm run build:main # tsc only (main process) @@ -84,7 +97,7 @@ After making UI changes: ### Sonos API -All Sonos HTTP calls go through `src/main.ts` → `sonosFetch()` → play.sonos.com BFF. The renderer calls `window.sonos.fetch({ operationId, pathParams, query, body })` — operationIds are defined in `SonosWebPlayerAPI.yaml` at the repo root. +All Sonos HTTP calls go through `src/main.ts` → `sonosFetch()` → play.sonos.com BFF. The renderer calls `window.sonos.fetch({ operationId, pathParams, query, body })` — operationIds are defined in `SonosWebPlayerAPI.yaml` in `apps/desktop`. `renderer/src/lib/sonosApi.ts` is the typed wrapper around `window.sonos.fetch` — add new API calls here. @@ -167,7 +180,7 @@ Main process (`src/`), IPC handlers, WebSocket bootstrap, auth flow, and most ho ## Gotchas - **`globals.d.ts` must stay ambient** — no `export {}`, no `import`. Adding either makes it a module and breaks global type resolution silently in the renderer. -- **Renderer typecheck is separate** — `tsc` at the root only checks `src/`. Always run `npm run typecheck` (which checks both) not just `tsc`. +- **Renderer typecheck is separate** — `tsc` in `apps/desktop` only checks `src/`. Always run `npm run typecheck` (which checks both) not just `tsc`. - **`applyReorderLocally` index math** — `insertAt` is the count of non-selected items whose _original_ index is less than `toIndex`, not `toIndex` itself. See `queueHelpers.ts` for comments. - **Image cache is a singleton module** — tests that import `imageCache` must use `vi.resetModules()` and dynamic `import()` in `beforeEach` to avoid state leaking between tests. - **Sandbox mode** — both `uiWin` and `miniWin` have `sandbox: true`. The preload script runs in the sandboxed context; do not use Node.js APIs directly in `preload.ts`. diff --git a/README.md b/README.md index 471481e..b0f5108 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,19 @@ On first launch you will be prompted to sign in with your Sonos account. After s ```bash git clone https://github.com/TrueNorthIT/TrueTunes -cd true-tunes-2 -npm install -npm run dev +cd TrueTunes +npm install # one install at the root for every workspace +npm run desktop # same as: cd apps/desktop && npm run dev ``` -`npm run dev` builds the main process then starts the Vite dev server and Electron concurrently. Hot module replacement works in the renderer; main process changes require restarting. +The repo is an npm-workspaces monorepo: the desktop app is in `apps/desktop`, the Android/iOS app in `apps/mobile`, and +`server/` is a separate package with its own lockfile. Run desktop scripts from `apps/desktop` (or with `-w apps/desktop` +from the root). `npm run desktop` builds the main process then starts the Vite dev server and Electron concurrently. Hot module replacement works in the renderer; main process changes require restarting. ### Environment variables (optional) -Create a `.env` file in the repo root (it is gitignored): +Create a `.env` file in the repo root (it is gitignored; `server/scripts/deploy.mjs` reads it too). The desktop build +bakes the values into `dist/build-env.json`, so `npm run dev` picks them up: ``` APPINSIGHTS_CONNECTION_STRING=InstrumentationKey=... @@ -56,6 +59,7 @@ Without this the app works normally — telemetry is a silent no-op. ## Project structure ``` +apps/desktop/ Desktop Electron app (paths below are relative to it) src/ Main process (Electron, Node.js) main.ts Entry point — BrowserWindow, IPC handlers, WebSocket, auth preload.ts contextBridge — exposes window.sonos API to renderer @@ -72,6 +76,8 @@ renderer/src/ Renderer process (React + Vite) types/ Ambient type declarations (globals.d.ts — window.sonos shape) test/setup.ts Vitest global setup — mocks window.sonos +apps/mobile/ React Native (Expo) Android/iOS app — see apps/mobile/CLAUDE.md + server/ Azure Functions (office presence backend) src/functions/ connect.ts Web PubSub connection negotiation @@ -80,17 +86,18 @@ server/ Azure Functions (office presence backend) azuredeploy.json ARM template — deploys all Azure infrastructure .github/workflows/ - desktop.yml CI — validate (lint + typecheck + tests) → version bump → publish (ignores apps/mobile) + desktop.yml CI — validate (lint + typecheck + tests) → version bump → publish (desktop paths only) mobile-ci.yml Mobile app CI (apps/mobile) mobile-release.yml Signed APK release on an android-vX.Y.Z tag - -apps/mobile/ React Native (Expo) Android/iOS app — see apps/mobile/CLAUDE.md ``` --- ## Scripts +Run these from `apps/desktop`. At the root, `npm run lint`, `npm run typecheck` and `npm run test:run` run every +workspace (lint also covers `server/`). + | Command | What it does | |---|---| | `npm run dev` | Start Electron app in dev mode | diff --git a/apps/mobile/CLAUDE.md b/apps/mobile/CLAUDE.md index a1c0985..99450f8 100644 --- a/apps/mobile/CLAUDE.md +++ b/apps/mobile/CLAUDE.md @@ -4,8 +4,12 @@ Sideloaded Android client for True Tunes, the Sonos web-player client. Expo SDK 57 / React Native 0.86 (**New Architecture only**), TypeScript, expo-router. It lives in `apps/mobile` of the public `TrueNorthIT/TrueTunes` -monorepo, beside the desktop Electron app (repo root); the Azure Functions backend in the root `server/` is shared and -unchanged. Run every command below from `apps/mobile`. +npm-workspaces monorepo, beside the desktop Electron app (`apps/desktop`); the Azure Functions backend in the root +`server/` is shared and unchanged. Run every command below from `apps/mobile`, except `npm install`/`npm ci`, which run +once at the repo root: there is a single root `package-lock.json` and dependencies are hoisted to the root +`node_modules`. Expo configures Metro for the monorepo itself — do not add `watchFolders` or +`disableHierarchicalLookup`. The root `overrides` pin `react`/`react-dom` (to the SDK's version), `@tanstack/react-query` +and `@types/react`; change them when the SDK moves. **Distribution is sideload only (APK) — never the Play Store.** The Sonos "API" is the reverse-engineered play.sonos.com BFF, exactly as on desktop. @@ -112,8 +116,8 @@ UI components (`src/ui`, `src/app`) have **no automated tests** — keep logic i ## Sharing code with the desktop app -`src/sonos`, `src/office`, `src/core` and `src/logic` are ported from the desktop app (repo root `src/` and -`renderer/src/`) and will drift unless mirrored. The intended end state is a monorepo with a shared `packages/core`; until then, mirror protocol fixes by hand. +`src/sonos`, `src/office`, `src/core` and `src/logic` are ported from the desktop app (`apps/desktop/src/` and +`apps/desktop/renderer/src/`) and will drift unless mirrored. The intended end state is a monorepo with a shared `packages/core`; until then, mirror protocol fixes by hand. `OPERATIONS` in `src/sonos/operations.ts` must match the desktop table. ## Infrastructure From 8da1ee7c3fa86597dd4d1de3b741cfb619d6b92c Mon Sep 17 00:00:00 2001 From: Joe Pitts Date: Fri, 9 Oct 2026 12:07:36 +0100 Subject: [PATCH 22/24] feat(core): move the platform-neutral modules into packages/core - git mv apps/mobile/src/{sonos,office,core} and their tests into packages/core (@truetunes/core): private, TypeScript source with no build step, exported by subpath (@truetunes/core/sonos/*, office/*, core/*). None of them import React Native or Expo. - Core has its own Vitest config (node environment), typecheck and ESLint config; no-restricted-imports makes react-native, expo* and react-dom an error there. Its tsconfig includes the mobile app's ambient window.sonos contract (src/types/globals.d.ts), which createCore satisfies. - The mobile app depends on @truetunes/core and imports it by subpath; its node test project now only covers the config plugins. - core.yml runs core's lint, typecheck and tests in CI. The root lint, typecheck and test:run scripts already cover every workspace. - The desktop does not use core yet. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UMj5XGHLroJb9q68jXkw8B --- .github/workflows/core.yml | 78 +++++++++++++++++++ .gitignore | 1 + apps/mobile/package.json | 1 + .../lib/__tests__/recentSearches.test.ts | 2 +- apps/mobile/src/logic/lib/recentSearches.ts | 2 +- apps/mobile/src/platform/SonosLogin.tsx | 4 +- apps/mobile/src/platform/core.ts | 2 +- apps/mobile/src/platform/createPlatform.ts | 2 +- apps/mobile/src/platform/secureStore.ts | 2 +- apps/mobile/src/platform/sockets.ts | 4 +- apps/mobile/src/platform/sonosCookie.ts | 2 +- .../src/platform/useConnectionStatus.ts | 2 +- apps/mobile/src/spike/SpikeScreen.tsx | 10 +-- apps/mobile/vitest.config.ts | 8 +- package-lock.json | 39 ++++++---- packages/core/eslint.config.mjs | 29 +++++++ packages/core/package.json | 28 +++++++ .../src/core/__tests__/createCore.test.ts | 0 .../core}/src/core/__tests__/events.test.ts | 0 .../core}/src/core/__tests__/loginUrl.test.ts | 0 .../core}/src/core/__tests__/session.test.ts | 0 .../core}/src/core/createCore.ts | 0 .../core}/src/core/events.ts | 0 .../core}/src/core/loginUrl.ts | 0 .../core}/src/core/session.ts | 0 .../src/office/__tests__/azureApi.test.ts | 0 .../core}/src/office/__tests__/config.test.ts | 0 .../core}/src/office/__tests__/genius.test.ts | 0 .../office/__tests__/graphPresence.test.ts | 0 .../office/__tests__/officePresence.test.ts | 0 .../core}/src/office/__tests__/pubsub.test.ts | 0 .../src/office/__tests__/telemetry.test.ts | 0 .../core}/src/office/azureApi.ts | 0 .../core}/src/office/config.ts | 0 .../core}/src/office/genius.ts | 0 .../core}/src/office/graphPresence.ts | 0 .../core}/src/office/officePresence.ts | 0 .../core}/src/office/pubsub.ts | 0 .../core}/src/office/telemetry.ts | 0 .../src/sonos/__tests__/queueOps.test.ts | 0 .../src/sonos/__tests__/sonosClient.test.ts | 0 .../src/sonos/__tests__/wsSession.test.ts | 0 .../core}/src/sonos/constants.ts | 0 .../core}/src/sonos/operations.ts | 0 .../core}/src/sonos/protocol.ts | 0 .../core}/src/sonos/queueOps.ts | 0 .../core}/src/sonos/sonosClient.ts | 0 .../core}/src/sonos/types.ts | 0 .../core}/src/sonos/wsClient.ts | 0 .../core}/src/sonos/wsSession.ts | 0 packages/core/tsconfig.json | 19 +++++ packages/core/vitest.config.ts | 14 ++++ 52 files changed, 210 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/core.yml create mode 100644 packages/core/eslint.config.mjs create mode 100644 packages/core/package.json rename {apps/mobile => packages/core}/src/core/__tests__/createCore.test.ts (100%) rename {apps/mobile => packages/core}/src/core/__tests__/events.test.ts (100%) rename {apps/mobile => packages/core}/src/core/__tests__/loginUrl.test.ts (100%) rename {apps/mobile => packages/core}/src/core/__tests__/session.test.ts (100%) rename {apps/mobile => packages/core}/src/core/createCore.ts (100%) rename {apps/mobile => packages/core}/src/core/events.ts (100%) rename {apps/mobile => packages/core}/src/core/loginUrl.ts (100%) rename {apps/mobile => packages/core}/src/core/session.ts (100%) rename {apps/mobile => packages/core}/src/office/__tests__/azureApi.test.ts (100%) rename {apps/mobile => packages/core}/src/office/__tests__/config.test.ts (100%) rename {apps/mobile => packages/core}/src/office/__tests__/genius.test.ts (100%) rename {apps/mobile => packages/core}/src/office/__tests__/graphPresence.test.ts (100%) rename {apps/mobile => packages/core}/src/office/__tests__/officePresence.test.ts (100%) rename {apps/mobile => packages/core}/src/office/__tests__/pubsub.test.ts (100%) rename {apps/mobile => packages/core}/src/office/__tests__/telemetry.test.ts (100%) rename {apps/mobile => packages/core}/src/office/azureApi.ts (100%) rename {apps/mobile => packages/core}/src/office/config.ts (100%) rename {apps/mobile => packages/core}/src/office/genius.ts (100%) rename {apps/mobile => packages/core}/src/office/graphPresence.ts (100%) rename {apps/mobile => packages/core}/src/office/officePresence.ts (100%) rename {apps/mobile => packages/core}/src/office/pubsub.ts (100%) rename {apps/mobile => packages/core}/src/office/telemetry.ts (100%) rename {apps/mobile => packages/core}/src/sonos/__tests__/queueOps.test.ts (100%) rename {apps/mobile => packages/core}/src/sonos/__tests__/sonosClient.test.ts (100%) rename {apps/mobile => packages/core}/src/sonos/__tests__/wsSession.test.ts (100%) rename {apps/mobile => packages/core}/src/sonos/constants.ts (100%) rename {apps/mobile => packages/core}/src/sonos/operations.ts (100%) rename {apps/mobile => packages/core}/src/sonos/protocol.ts (100%) rename {apps/mobile => packages/core}/src/sonos/queueOps.ts (100%) rename {apps/mobile => packages/core}/src/sonos/sonosClient.ts (100%) rename {apps/mobile => packages/core}/src/sonos/types.ts (100%) rename {apps/mobile => packages/core}/src/sonos/wsClient.ts (100%) rename {apps/mobile => packages/core}/src/sonos/wsSession.ts (100%) create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/vitest.config.ts diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml new file mode 100644 index 0000000..bfb1df1 --- /dev/null +++ b/.github/workflows/core.yml @@ -0,0 +1,78 @@ +name: Core + +# packages/core (@truetunes/core): typecheck, lint (including the platform-neutral import ban) and Node tests. +on: + push: + branches: [main] + paths: + - 'packages/core/**' + - 'apps/mobile/src/types/globals.d.ts' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/core.yml' + pull_request: + branches: [main, develop] + paths: + - 'packages/core/**' + - 'apps/mobile/src/types/globals.d.ts' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/core.yml' + +permissions: + contents: read + checks: write + pull-requests: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + +defaults: + run: + working-directory: packages/core + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - run: npm ci + working-directory: . + + - run: npm run lint + + - run: npm run typecheck + + - name: Test with coverage + run: > + npm run test:run -- + --reporter=default + --reporter=junit + --outputFile.junit=./junit.xml + --coverage + + - name: Publish test results + uses: dorny/test-reporter@v1 + if: always() + with: + name: Core Vitest Tests + path: packages/core/junit.xml + reporter: java-junit + fail-on-error: true + + - name: Publish coverage report + uses: davelosert/vitest-coverage-report-action@v2 + if: always() + with: + name: Core + json-summary-path: packages/core/coverage/coverage-summary.json + json-final-path: packages/core/coverage/coverage-final.json + vite-config-path: packages/core/vitest.config.ts diff --git a/.gitignore b/.gitignore index a03d615..b4c5862 100644 --- a/.gitignore +++ b/.gitignore @@ -152,3 +152,4 @@ apps/desktop/native/smtc/build/ # entries (incl. credential-reading commands); must never be committed. .claude/settings.local.json server/dist-test/ +junit.xml diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 8ef3db1..8dceadb 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "main": "expo-router/entry", "dependencies": { + "@truetunes/core": "*", "@preeternal/react-native-cookie-manager": "^7.0.0", "@shopify/flash-list": "2.0.2", "@tanstack/query-async-storage-persister": "^5.104.0", diff --git a/apps/mobile/src/logic/lib/__tests__/recentSearches.test.ts b/apps/mobile/src/logic/lib/__tests__/recentSearches.test.ts index ed864eb..01bac81 100644 --- a/apps/mobile/src/logic/lib/__tests__/recentSearches.test.ts +++ b/apps/mobile/src/logic/lib/__tests__/recentSearches.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import type { KeyValueStore } from '../../../office/config'; +import type { KeyValueStore } from '@truetunes/core/office/config'; import { addRecent, clearRecent, diff --git a/apps/mobile/src/logic/lib/recentSearches.ts b/apps/mobile/src/logic/lib/recentSearches.ts index 13fd217..267f27e 100644 --- a/apps/mobile/src/logic/lib/recentSearches.ts +++ b/apps/mobile/src/logic/lib/recentSearches.ts @@ -1,5 +1,5 @@ // Recent search queries as pure logic over an injected KeyValueStore. -import type { KeyValueStore } from '../../office/config'; +import type { KeyValueStore } from '@truetunes/core/office/config'; export const RECENT_SEARCHES_KEY = 'recent.searches'; export const MAX_RECENT = 10; diff --git a/apps/mobile/src/platform/SonosLogin.tsx b/apps/mobile/src/platform/SonosLogin.tsx index 4f99607..6cd4dbe 100644 --- a/apps/mobile/src/platform/SonosLogin.tsx +++ b/apps/mobile/src/platform/SonosLogin.tsx @@ -1,7 +1,7 @@ import { useCallback, useRef } from 'react'; import { WebView } from 'react-native-webview'; -import { BASE_URL, SPOOF_UA } from '../sonos/constants'; -import { looksSignedIn } from '../core/loginUrl'; +import { BASE_URL, SPOOF_UA } from '@truetunes/core/sonos/constants'; +import { looksSignedIn } from '@truetunes/core/core/loginUrl'; import { readSonosSessionToken } from './sonosCookie'; /** diff --git a/apps/mobile/src/platform/core.ts b/apps/mobile/src/platform/core.ts index 40679c9..acd4bf7 100644 --- a/apps/mobile/src/platform/core.ts +++ b/apps/mobile/src/platform/core.ts @@ -1,7 +1,7 @@ // Process-wide core singleton. Importing this module installs `window.sonos` (the same surface the // desktop preload exposes) BEFORE any ported hook mounts, so the hooks run unchanged. -import { createCore } from '../core/createCore'; +import { createCore } from '@truetunes/core/core/createCore'; import { createPlatform } from './createPlatform'; // React Native aliases `window` to the global object; make that explicit for safety. diff --git a/apps/mobile/src/platform/createPlatform.ts b/apps/mobile/src/platform/createPlatform.ts index 2350085..734ad21 100644 --- a/apps/mobile/src/platform/createPlatform.ts +++ b/apps/mobile/src/platform/createPlatform.ts @@ -1,6 +1,6 @@ import Constants from 'expo-constants'; import { Linking } from 'react-native'; -import type { CorePlatform } from '../core/createCore'; +import type { CorePlatform } from '@truetunes/core/core/createCore'; import { createPubSubSocket, createSonosSocket } from './sockets'; import { secureKeyValueStore, secureTokenStore } from './secureStore'; diff --git a/apps/mobile/src/platform/secureStore.ts b/apps/mobile/src/platform/secureStore.ts index 93ed9fd..4e4c243 100644 --- a/apps/mobile/src/platform/secureStore.ts +++ b/apps/mobile/src/platform/secureStore.ts @@ -2,7 +2,7 @@ // safeStorage (session token) and the plaintext config.json in userData. import * as SecureStore from 'expo-secure-store'; -import type { TokenStore } from '../core/session'; +import type { TokenStore } from '@truetunes/core/core/session'; const TOKEN_KEY = 'sonos.sessionToken'; diff --git a/apps/mobile/src/platform/sockets.ts b/apps/mobile/src/platform/sockets.ts index d75a95a..af0dd06 100644 --- a/apps/mobile/src/platform/sockets.ts +++ b/apps/mobile/src/platform/sockets.ts @@ -2,8 +2,8 @@ // argument carrying request headers, which the Sonos endpoint needs (Origin, User-Agent). // Verified on a Pixel 10 Pro XL in the Phase 0 spike. -import type { PubSubSocket } from '../office/pubsub'; -import type { SocketFactory, SocketLike } from '../sonos/wsSession'; +import type { PubSubSocket } from '@truetunes/core/office/pubsub'; +import type { SocketFactory, SocketLike } from '@truetunes/core/sonos/wsSession'; type HeaderWebSocketCtor = new ( url: string, diff --git a/apps/mobile/src/platform/sonosCookie.ts b/apps/mobile/src/platform/sonosCookie.ts index e3ce293..bb2febc 100644 --- a/apps/mobile/src/platform/sonosCookie.ts +++ b/apps/mobile/src/platform/sonosCookie.ts @@ -1,5 +1,5 @@ import CookieManager from '@preeternal/react-native-cookie-manager'; -import { BASE_URL, SESSION_COOKIE } from '../sonos/constants'; +import { BASE_URL, SESSION_COOKIE } from '@truetunes/core/sonos/constants'; /** The HttpOnly next-auth session token from the WebView's cookie jar (native read), or null. */ export async function readSonosSessionToken(): Promise { diff --git a/apps/mobile/src/platform/useConnectionStatus.ts b/apps/mobile/src/platform/useConnectionStatus.ts index 5a9ad9d..60984e2 100644 --- a/apps/mobile/src/platform/useConnectionStatus.ts +++ b/apps/mobile/src/platform/useConnectionStatus.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import type { WsStatus } from '../sonos/wsSession'; +import type { WsStatus } from '@truetunes/core/sonos/wsSession'; import { core } from './core'; /** Live Sonos WebSocket state. The hub replays the latest value, so late mounts are correct. */ diff --git a/apps/mobile/src/spike/SpikeScreen.tsx b/apps/mobile/src/spike/SpikeScreen.tsx index 9f837a3..17814ac 100644 --- a/apps/mobile/src/spike/SpikeScreen.tsx +++ b/apps/mobile/src/spike/SpikeScreen.tsx @@ -3,11 +3,11 @@ import * as SecureStore from 'expo-secure-store'; import { useCallback, useRef, useState } from 'react'; import { Pressable, SafeAreaView, ScrollView, StatusBar, StyleSheet, Text, View } from 'react-native'; import { WebView } from 'react-native-webview'; -import { BASE_URL, SESSION_COOKIE, SPOOF_UA } from '../sonos/constants'; -import { extractGroups, findTicket } from '../sonos/protocol'; -import { createSonosClient } from '../sonos/sonosClient'; -import type { SonosConfig } from '../sonos/types'; -import { SonosWs } from '../sonos/wsClient'; +import { BASE_URL, SESSION_COOKIE, SPOOF_UA } from '@truetunes/core/sonos/constants'; +import { extractGroups, findTicket } from '@truetunes/core/sonos/protocol'; +import { createSonosClient } from '@truetunes/core/sonos/sonosClient'; +import type { SonosConfig } from '@truetunes/core/sonos/types'; +import { SonosWs } from '@truetunes/core/sonos/wsClient'; const TOKEN_KEY = 'sonos.sessionToken'; const mask = (t: string) => `${t.slice(0, 8)}...(${t.length} chars)`; diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index d752aae..174e47e 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -13,12 +13,8 @@ export default defineConfig({ test: { name: 'node', environment: 'node', - include: [ - 'src/sonos/**/__tests__/**/*.test.{ts,tsx}', - 'src/office/**/__tests__/**/*.test.{ts,tsx}', - 'src/core/**/__tests__/**/*.test.{ts,tsx}', - 'plugins/**/__tests__/**/*.test.js', - ], + // src/sonos, src/office and src/core now live in packages/core, which runs its own tests. + include: ['plugins/**/__tests__/**/*.test.js'], }, }, { diff --git a/package-lock.json b/package-lock.json index 144e672..688dac6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "@tanstack/query-async-storage-persister": "^5.104.0", "@tanstack/react-query": "^5.104.0", "@tanstack/react-query-persist-client": "^5.104.0", + "@truetunes/core": "*", "expo": "~57.0.27", "expo-build-properties": "~57.0.22", "expo-constants": "~57.0.20", @@ -156,20 +157,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "apps/mobile/node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/@adobe/css-tools": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", @@ -7731,6 +7718,10 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "license": "MIT" }, + "node_modules/@truetunes/core": { + "resolved": "packages/core", + "link": true + }, "node_modules/@tybys/wasm-util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", @@ -18619,9 +18610,9 @@ } }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -19506,6 +19497,20 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "packages/core": { + "name": "@truetunes/core", + "version": "0.0.0", + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^25.6.0", + "@vitest/coverage-v8": "^5.0.2", + "eslint": "^10.11.0", + "eslint-config-prettier": "^10.1.8", + "typescript": "~6.0.3", + "typescript-eslint": "^8.71.0", + "vitest": "^5.0.2" + } } } } diff --git a/packages/core/eslint.config.mjs b/packages/core/eslint.config.mjs new file mode 100644 index 0000000..5c2356f --- /dev/null +++ b/packages/core/eslint.config.mjs @@ -0,0 +1,29 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import prettier from 'eslint-config-prettier'; + +export default tseslint.config( + { ignores: ['node_modules/**', 'coverage/**'] }, + + { + files: ['**/*.ts'], + extends: [js.configs.recommended, ...tseslint.configs.recommended, prettier], + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + '@typescript-eslint/no-non-null-assertion': 'warn', + // Core runs in Node tests, the React Native app and (later) Electron: everything environmental is injected. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['react-native', 'react-native/*', 'expo', 'expo/*', 'expo-*', '@expo/*', 'react-dom', 'react-dom/*'], + message: 'packages/core must stay platform-neutral: inject the capability from the app instead.', + }, + ], + }, + ], + }, + }, +); diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..eec64ca --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,28 @@ +{ + "name": "@truetunes/core", + "version": "0.0.0", + "private": true, + "description": "Platform-neutral Sonos, Azure and session modules shared by the True Tunes apps. TypeScript source, no build step.", + "exports": { + "./sonos/*": "./src/sonos/*.ts", + "./office/*": "./src/office/*.ts", + "./core/*": "./src/core/*.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^25.6.0", + "@vitest/coverage-v8": "^5.0.2", + "eslint": "^10.11.0", + "eslint-config-prettier": "^10.1.8", + "typescript": "~6.0.3", + "typescript-eslint": "^8.71.0", + "vitest": "^5.0.2" + } +} diff --git a/apps/mobile/src/core/__tests__/createCore.test.ts b/packages/core/src/core/__tests__/createCore.test.ts similarity index 100% rename from apps/mobile/src/core/__tests__/createCore.test.ts rename to packages/core/src/core/__tests__/createCore.test.ts diff --git a/apps/mobile/src/core/__tests__/events.test.ts b/packages/core/src/core/__tests__/events.test.ts similarity index 100% rename from apps/mobile/src/core/__tests__/events.test.ts rename to packages/core/src/core/__tests__/events.test.ts diff --git a/apps/mobile/src/core/__tests__/loginUrl.test.ts b/packages/core/src/core/__tests__/loginUrl.test.ts similarity index 100% rename from apps/mobile/src/core/__tests__/loginUrl.test.ts rename to packages/core/src/core/__tests__/loginUrl.test.ts diff --git a/apps/mobile/src/core/__tests__/session.test.ts b/packages/core/src/core/__tests__/session.test.ts similarity index 100% rename from apps/mobile/src/core/__tests__/session.test.ts rename to packages/core/src/core/__tests__/session.test.ts diff --git a/apps/mobile/src/core/createCore.ts b/packages/core/src/core/createCore.ts similarity index 100% rename from apps/mobile/src/core/createCore.ts rename to packages/core/src/core/createCore.ts diff --git a/apps/mobile/src/core/events.ts b/packages/core/src/core/events.ts similarity index 100% rename from apps/mobile/src/core/events.ts rename to packages/core/src/core/events.ts diff --git a/apps/mobile/src/core/loginUrl.ts b/packages/core/src/core/loginUrl.ts similarity index 100% rename from apps/mobile/src/core/loginUrl.ts rename to packages/core/src/core/loginUrl.ts diff --git a/apps/mobile/src/core/session.ts b/packages/core/src/core/session.ts similarity index 100% rename from apps/mobile/src/core/session.ts rename to packages/core/src/core/session.ts diff --git a/apps/mobile/src/office/__tests__/azureApi.test.ts b/packages/core/src/office/__tests__/azureApi.test.ts similarity index 100% rename from apps/mobile/src/office/__tests__/azureApi.test.ts rename to packages/core/src/office/__tests__/azureApi.test.ts diff --git a/apps/mobile/src/office/__tests__/config.test.ts b/packages/core/src/office/__tests__/config.test.ts similarity index 100% rename from apps/mobile/src/office/__tests__/config.test.ts rename to packages/core/src/office/__tests__/config.test.ts diff --git a/apps/mobile/src/office/__tests__/genius.test.ts b/packages/core/src/office/__tests__/genius.test.ts similarity index 100% rename from apps/mobile/src/office/__tests__/genius.test.ts rename to packages/core/src/office/__tests__/genius.test.ts diff --git a/apps/mobile/src/office/__tests__/graphPresence.test.ts b/packages/core/src/office/__tests__/graphPresence.test.ts similarity index 100% rename from apps/mobile/src/office/__tests__/graphPresence.test.ts rename to packages/core/src/office/__tests__/graphPresence.test.ts diff --git a/apps/mobile/src/office/__tests__/officePresence.test.ts b/packages/core/src/office/__tests__/officePresence.test.ts similarity index 100% rename from apps/mobile/src/office/__tests__/officePresence.test.ts rename to packages/core/src/office/__tests__/officePresence.test.ts diff --git a/apps/mobile/src/office/__tests__/pubsub.test.ts b/packages/core/src/office/__tests__/pubsub.test.ts similarity index 100% rename from apps/mobile/src/office/__tests__/pubsub.test.ts rename to packages/core/src/office/__tests__/pubsub.test.ts diff --git a/apps/mobile/src/office/__tests__/telemetry.test.ts b/packages/core/src/office/__tests__/telemetry.test.ts similarity index 100% rename from apps/mobile/src/office/__tests__/telemetry.test.ts rename to packages/core/src/office/__tests__/telemetry.test.ts diff --git a/apps/mobile/src/office/azureApi.ts b/packages/core/src/office/azureApi.ts similarity index 100% rename from apps/mobile/src/office/azureApi.ts rename to packages/core/src/office/azureApi.ts diff --git a/apps/mobile/src/office/config.ts b/packages/core/src/office/config.ts similarity index 100% rename from apps/mobile/src/office/config.ts rename to packages/core/src/office/config.ts diff --git a/apps/mobile/src/office/genius.ts b/packages/core/src/office/genius.ts similarity index 100% rename from apps/mobile/src/office/genius.ts rename to packages/core/src/office/genius.ts diff --git a/apps/mobile/src/office/graphPresence.ts b/packages/core/src/office/graphPresence.ts similarity index 100% rename from apps/mobile/src/office/graphPresence.ts rename to packages/core/src/office/graphPresence.ts diff --git a/apps/mobile/src/office/officePresence.ts b/packages/core/src/office/officePresence.ts similarity index 100% rename from apps/mobile/src/office/officePresence.ts rename to packages/core/src/office/officePresence.ts diff --git a/apps/mobile/src/office/pubsub.ts b/packages/core/src/office/pubsub.ts similarity index 100% rename from apps/mobile/src/office/pubsub.ts rename to packages/core/src/office/pubsub.ts diff --git a/apps/mobile/src/office/telemetry.ts b/packages/core/src/office/telemetry.ts similarity index 100% rename from apps/mobile/src/office/telemetry.ts rename to packages/core/src/office/telemetry.ts diff --git a/apps/mobile/src/sonos/__tests__/queueOps.test.ts b/packages/core/src/sonos/__tests__/queueOps.test.ts similarity index 100% rename from apps/mobile/src/sonos/__tests__/queueOps.test.ts rename to packages/core/src/sonos/__tests__/queueOps.test.ts diff --git a/apps/mobile/src/sonos/__tests__/sonosClient.test.ts b/packages/core/src/sonos/__tests__/sonosClient.test.ts similarity index 100% rename from apps/mobile/src/sonos/__tests__/sonosClient.test.ts rename to packages/core/src/sonos/__tests__/sonosClient.test.ts diff --git a/apps/mobile/src/sonos/__tests__/wsSession.test.ts b/packages/core/src/sonos/__tests__/wsSession.test.ts similarity index 100% rename from apps/mobile/src/sonos/__tests__/wsSession.test.ts rename to packages/core/src/sonos/__tests__/wsSession.test.ts diff --git a/apps/mobile/src/sonos/constants.ts b/packages/core/src/sonos/constants.ts similarity index 100% rename from apps/mobile/src/sonos/constants.ts rename to packages/core/src/sonos/constants.ts diff --git a/apps/mobile/src/sonos/operations.ts b/packages/core/src/sonos/operations.ts similarity index 100% rename from apps/mobile/src/sonos/operations.ts rename to packages/core/src/sonos/operations.ts diff --git a/apps/mobile/src/sonos/protocol.ts b/packages/core/src/sonos/protocol.ts similarity index 100% rename from apps/mobile/src/sonos/protocol.ts rename to packages/core/src/sonos/protocol.ts diff --git a/apps/mobile/src/sonos/queueOps.ts b/packages/core/src/sonos/queueOps.ts similarity index 100% rename from apps/mobile/src/sonos/queueOps.ts rename to packages/core/src/sonos/queueOps.ts diff --git a/apps/mobile/src/sonos/sonosClient.ts b/packages/core/src/sonos/sonosClient.ts similarity index 100% rename from apps/mobile/src/sonos/sonosClient.ts rename to packages/core/src/sonos/sonosClient.ts diff --git a/apps/mobile/src/sonos/types.ts b/packages/core/src/sonos/types.ts similarity index 100% rename from apps/mobile/src/sonos/types.ts rename to packages/core/src/sonos/types.ts diff --git a/apps/mobile/src/sonos/wsClient.ts b/packages/core/src/sonos/wsClient.ts similarity index 100% rename from apps/mobile/src/sonos/wsClient.ts rename to packages/core/src/sonos/wsClient.ts diff --git a/apps/mobile/src/sonos/wsSession.ts b/packages/core/src/sonos/wsSession.ts similarity index 100% rename from apps/mobile/src/sonos/wsSession.ts rename to packages/core/src/sonos/wsSession.ts diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..cdaa88d --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,19 @@ +{ + // Same module settings as the mobile app (expo/tsconfig.base) without depending on Expo. + "compilerOptions": { + "strict": true, + "target": "ESNext", + "lib": ["DOM", "ESNext"], + "module": "preserve", + "moduleDetection": "force", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + // createCore implements the window.sonos contract (SonosPreload), which is ambient in the mobile app. + // ponytail: core reads the app's contract file; move the contract into core when the desktop adopts core. + "include": ["src", "../../apps/mobile/src/types/globals.d.ts"] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..1f0a85a --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/__tests__/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/**/__tests__/**'], + reporter: ['text-summary', 'json-summary', 'json'], + }, + }, +}); From 8b7b32446e561ae67fa26e9702ca45b13b6840d6 Mon Sep 17 00:00:00 2001 From: Joe Pitts Date: Fri, 9 Oct 2026 12:07:42 +0100 Subject: [PATCH 23/24] docs: describe packages/core and how to add a capability to it Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UMj5XGHLroJb9q68jXkw8B --- CLAUDE.md | 20 ++++++++-------- README.md | 33 ++++++++++++++------------ apps/mobile/CLAUDE.md | 54 +++++++++++++++++++++++++------------------ apps/mobile/README.md | 40 +++++++++++++++++--------------- 4 files changed, 82 insertions(+), 65 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 35344a7..85a80de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,12 @@ This repo is **public** and is an **npm-workspaces monorepo** (`apps/*`, `packages/*`): -| Path | What | -| -------------- | ------------------------------------------------------------------------------------------------------- | -| `apps/desktop` | The desktop Electron app — the rest of this file. **Paths below are relative to `apps/desktop`** | -| `apps/mobile` | The Android/iOS React Native app (moved in from `TrueNorthIT/TrueTunes-Android` with its history). Read `apps/mobile/CLAUDE.md` before touching it | -| `server/` | Azure Functions backend. **Not a workspace**: own `package.json` and lockfile, because its deploy uploads `server/node_modules` | +| Path | What | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/desktop` | The desktop Electron app — the rest of this file. **Paths below are relative to `apps/desktop`** | +| `apps/mobile` | The Android/iOS React Native app (moved in from `TrueNorthIT/TrueTunes-Android` with its history). Read `apps/mobile/CLAUDE.md` before touching it | +| `packages/core` | `@truetunes/core`: platform-neutral Sonos/Azure/session modules (TypeScript source, no build). Used by mobile; **the desktop does not use it yet** | +| `server/` | Azure Functions backend. **Not a workspace**: own `package.json` and lockfile, because its deploy uploads `server/node_modules` | - One `npm install` at the root installs both apps; there is one root `package-lock.json`. Never add a lockfile under `apps/`. @@ -22,11 +23,12 @@ This repo is **public** and is an **npm-workspaces monorepo** (`apps/*`, `packag Workflows (`.github/workflows/`): -| File | What | -| -------------------- | ----------------------------------------------------------------------------------------------------- | +| File | What | +| -------------------- | ------------------------------------------------------------------------------------------------------ | | `desktop.yml` | Desktop PR validation; on push to `main`, bumps `apps/desktop`'s patch version and publishes a release | -| `mobile-ci.yml` | Mobile lint, typecheck, expo-doctor and tests, for changes under `apps/mobile` | -| `mobile-release.yml` | On an `android-vX.Y.Z` tag, builds and signs the APK and creates a release with `--latest=false` | +| `mobile-ci.yml` | Mobile lint, typecheck, expo-doctor and tests, for changes under `apps/mobile` | +| `core.yml` | `packages/core` lint (incl. the React Native/Expo import ban), typecheck and Node tests | +| `mobile-release.yml` | On an `android-vX.Y.Z` tag, builds and signs the APK and creates a release with `--latest=false` | - `desktop.yml` only runs for `apps/desktop/**`, `packages/**` and the root package files (PRs also for `server/**`). **Keep it that way**: otherwise every mobile commit to `main` publishes a desktop release. diff --git a/README.md b/README.md index b0f5108..05f15bf 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ renderer/src/ Renderer process (React + Vite) test/setup.ts Vitest global setup — mocks window.sonos apps/mobile/ React Native (Expo) Android/iOS app — see apps/mobile/CLAUDE.md +packages/core/ @truetunes/core — platform-neutral Sonos/Azure/session modules (used by mobile) server/ Azure Functions (office presence backend) src/functions/ @@ -88,6 +89,7 @@ server/ Azure Functions (office presence backend) .github/workflows/ desktop.yml CI — validate (lint + typecheck + tests) → version bump → publish (desktop paths only) mobile-ci.yml Mobile app CI (apps/mobile) + core.yml packages/core lint, typecheck and tests mobile-release.yml Signed APK release on an android-vX.Y.Z tag ``` @@ -98,18 +100,18 @@ server/ Azure Functions (office presence backend) Run these from `apps/desktop`. At the root, `npm run lint`, `npm run typecheck` and `npm run test:run` run every workspace (lint also covers `server/`). -| Command | What it does | -|---|---| -| `npm run dev` | Start Electron app in dev mode | -| `npm run build` | Build main process + renderer | -| `npm test` | Run tests in watch mode | -| `npm run test:run` | Run tests once (used in CI) | -| `npm run typecheck` | Type-check both main and renderer | -| `npm run lint` | ESLint | -| `npm run format` | Prettier | -| `npm run electron:build` | Package for current platform | -| `npm run electron:build:win` | Package for Windows | -| `npm run electron:build:mac` | Package for macOS | +| Command | What it does | +| ---------------------------- | --------------------------------- | +| `npm run dev` | Start Electron app in dev mode | +| `npm run build` | Build main process + renderer | +| `npm test` | Run tests in watch mode | +| `npm run test:run` | Run tests once (used in CI) | +| `npm run typecheck` | Type-check both main and renderer | +| `npm run lint` | ESLint | +| `npm run format` | Prettier | +| `npm run electron:build` | Package for current platform | +| `npm run electron:build:win` | Package for Windows | +| `npm run electron:build:mac` | Package for macOS | --- @@ -125,6 +127,7 @@ az deployment group create \ ``` Resources created: + - **Azure Function App** — presence/attribution backend - **Azure Web PubSub** — real-time office presence events - **Cosmos DB** — stores queue event history @@ -140,7 +143,7 @@ Every PR to `main` or `develop` runs lint, typecheck, and tests. Merging to `mai The publish job requires these GitHub Actions secrets: -| Secret | Description | -|---|---| -| `GITHUB_TOKEN` | Auto-provided by Actions | +| Secret | Description | +| ------------------------------- | ------------------------ | +| `GITHUB_TOKEN` | Auto-provided by Actions | | `APPINSIGHTS_CONNECTION_STRING` | From ARM template output | diff --git a/apps/mobile/CLAUDE.md b/apps/mobile/CLAUDE.md index 99450f8..7eb5ef4 100644 --- a/apps/mobile/CLAUDE.md +++ b/apps/mobile/CLAUDE.md @@ -46,25 +46,32 @@ changes come through Metro. ## Architecture -The desktop app's `src/main.ts` (Electron main process) is split into platform-neutral, injectable modules here, and -composed into an object with the **same `window.sonos` surface the desktop preload exposes** — so the React Query +The desktop app's `src/main.ts` (Electron main process) is split into platform-neutral, injectable modules in +**`packages/core`** (`@truetunes/core`, at the repo root), and composed into an object with the **same `window.sonos` surface the desktop preload exposes** — so the React Query hooks ported from the desktop renderer run unchanged. -| Folder | What | -| --------------- | ------------------------------------------------------------------------------------------------------- | -| `src/sonos/` | Sonos HTTP client (`sonosClient`), WebSocket session (`wsSession`), queue reorder, protocol helpers | -| `src/office/` | Azure Functions client, Web PubSub, Genius, Graph presence, telemetry | -| `src/core/` | Session/auth manager, event hub, `createCore` (composition root → `bridge`) | -| `src/platform/` | React Native adapters (SecureStore, sockets, cookies, login WebView, query persister) and the auth gate | -| `src/logic/` | Hooks/lib/providers ported from the desktop renderer, plus `useRemote` and add-to-queue | -| `src/ui/` | Shared components (theme tokens, Art, Slider, MiniPlayer, media rows, queue rows, …) | -| `src/app/` | **Routes only** (expo-router). Every file here is a screen — never put hooks/utils here | - -- Everything environmental (fetch, sockets, timers, storage) is **injected**, so `sonos/`, `office/` and `core/` run in - Node tests. `src/platform/core.ts` builds the real one and installs `window.sonos` before any hook mounts. -- Add a new capability: implement it in `sonos/` or `office/`, expose it in `createCore`'s `bridge` (it is - `satisfies SonosPreload`, so the compiler tells you what is missing), add the type to `src/types/globals.d.ts` - (**keep it a pure ambient file — no `import`/`export`**), and add the mock to `src/logic/test/setup.ts`. +| Folder | What | +| --------------------------- | ------------------------------------------------------------------------------------------------------- | +| `packages/core/src/sonos/` | Sonos HTTP client (`sonosClient`), WebSocket session (`wsSession`), queue reorder, protocol helpers | +| `packages/core/src/office/` | Azure Functions client, Web PubSub, Genius, Graph presence, telemetry | +| `packages/core/src/core/` | Session/auth manager, event hub, `createCore` (composition root → `bridge`) | +| `src/types/globals.d.ts` | The ambient `window.sonos` contract (`SonosPreload`) that `createCore` satisfies | +| `src/platform/` | React Native adapters (SecureStore, sockets, cookies, login WebView, query persister) and the auth gate | +| `src/logic/` | Hooks/lib/providers ported from the desktop renderer, plus `useRemote` and add-to-queue | +| `src/ui/` | Shared components (theme tokens, Art, Slider, MiniPlayer, media rows, queue rows, …) | +| `src/app/` | **Routes only** (expo-router). Every file here is a screen — never put hooks/utils here | + +- Import core by subpath: `@truetunes/core/sonos/`, `@truetunes/core/office/`, + `@truetunes/core/core/` (TypeScript source, no build step; Metro, Vitest and `tsc` read it directly). +- Everything environmental (fetch, sockets, timers, storage) is **injected**, so core runs in Node tests. Core must + never import `react-native`, `expo*` or `react-dom` — its ESLint config makes that an error. React Native adapters + live in `src/platform/`; `src/platform/core.ts` builds the real core and installs `window.sonos` before any hook + mounts. +- Add a new capability: implement it in `packages/core/src/sonos/` or `office/` with a test there, expose it in + `createCore`'s `bridge` (it is `satisfies SonosPreload`, so the compiler tells you what is missing), add the type to + `src/types/globals.d.ts` here (**keep it a pure ambient file — no `import`/`export`**; core's `tsconfig.json` includes + it), and add the mock to `src/logic/test/setup.ts`. Run core's checks too: `npm run typecheck`, `lint` and + `test:run` in `packages/core`. - `src/logic/` intentionally tracks the desktop renderer. When you change something there that also exists on desktop, mirror it (see "Sharing code" below). @@ -80,7 +87,9 @@ uniqueness claim). ## Tests -Vitest. Two projects in `vitest.config.ts`: `node` (`src/sonos`, `src/office`, `src/core`) and `app` (jsdom, `src/logic`). +Vitest. Two projects in `vitest.config.ts`: `node` (the config plugins in `plugins/`) and `app` (jsdom, `src/logic`). +Core's tests run under `packages/core/vitest.config.ts` (node environment) — `npm run test:run` there, or at the repo +root for every workspace. UI components (`src/ui`, `src/app`) have **no automated tests** — keep logic in plain functions/hooks under `src/logic` (or a tested pure module) and test that. Query-option factories are tested by calling `queryFn()` directly. @@ -88,7 +97,8 @@ UI components (`src/ui`, `src/app`) have **no automated tests** — keep logic i ## Adding a feature — checklist -1. Needs Sonos/Azure data? Add it to `sonos/`/`office/` with a test, expose it on the `bridge` (see above). +1. Needs Sonos/Azure data? Add it to `packages/core` (`sonos/`/`office/`) with a test, expose it on the `bridge` (see + above). 2. UI goes in `src/ui/` + a route in `src/app/`. Use the theme tokens in `src/ui/theme.ts`. 3. A new **native** dependency needs `npx expo install`, a clean `npx expo-doctor`, and a rebuild. 4. Run `npm run typecheck && npm run lint && npm run test:run` before committing. @@ -116,9 +126,9 @@ UI components (`src/ui`, `src/app`) have **no automated tests** — keep logic i ## Sharing code with the desktop app -`src/sonos`, `src/office`, `src/core` and `src/logic` are ported from the desktop app (`apps/desktop/src/` and -`apps/desktop/renderer/src/`) and will drift unless mirrored. The intended end state is a monorepo with a shared `packages/core`; until then, mirror protocol fixes by hand. -`OPERATIONS` in `src/sonos/operations.ts` must match the desktop table. +`packages/core` and `src/logic` are ported from the desktop app (`apps/desktop/src/` and `apps/desktop/renderer/src/`). +The desktop does **not** use `packages/core` yet, so the two still drift unless mirrored: mirror protocol fixes by hand. +`OPERATIONS` in `packages/core/src/sonos/operations.ts` must match the desktop table. ## Infrastructure diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 490f003..1df192f 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -13,9 +13,10 @@ the shell - see "Verify on device" below. ### Layout -- `src/sonos/` - Sonos client, WebSocket session, queue reorder (ported from desktop `main.ts`, unit tested) -- `src/office/` - Azure Functions client, pubsub, Genius, Graph presence, telemetry -- `src/core/` - session/auth manager, event hub, `createCore` (composes everything into a `window.sonos` bridge) +- `packages/core` (repo root, imported as `@truetunes/core/...`) - the platform-neutral modules: + - `src/sonos/` - Sonos client, WebSocket session, queue reorder (ported from desktop `main.ts`, unit tested) + - `src/office/` - Azure Functions client, pubsub, Genius, Graph presence, telemetry + - `src/core/` - session/auth manager, event hub, `createCore` (composes everything into a `window.sonos` bridge) - `src/platform/` - React Native adapters (SecureStore, sockets, cookies, login WebView) and the auth gate - `src/logic/` - hooks/lib/providers ported from the desktop renderer, plus `useRemote` and add-to-queue - `src/ui/` + `src/app/` - screens (expo-router) and shared components @@ -24,7 +25,7 @@ the shell - see "Verify on device" below. ### Not done / needs input - **Entra sign-in**: needs an Android platform + redirect URI on the app registration, then an `EntraProvider` - (see `src/core/createCore.ts`). Until then: no display-name claim, no office presence, Home "Recently + (see `packages/core/src/core/createCore.ts`). Until then: no display-name claim, no office presence, Home "Recently Played" and playlists stay empty (they key off the display name). - **Genius** (lyrics text, artist bios, album year) and **App Insights**: set `EXPO_PUBLIC_*` in `.env` (see `.env.example`). @@ -34,22 +35,23 @@ the shell - see "Verify on device" below. ### Verify on device Search, Now Playing (seek/volume gestures), room picker, queue drag-reorder and multi-select, add-to-queue -+ attribution, lyrics, leaderboard, profile image upload, Queuedle, and every modal presentation. + +- attribution, lyrics, leaderboard, profile image upload, Queuedle, and every modal presentation. ### Spike results (Pixel 10 Pro XL, Android, 2026-09-29) -| Check | Result | -| --- | --- | -| Sonos sign-in in an embedded WebView | PASS - normal Okta flow (login.sonos.com) works, not blocked | -| Read HttpOnly `__Secure-next-auth.session-token` natively | PASS - visible via `@preeternal/react-native-cookie-manager`; cookie `expires` ~10 days out | -| RN `fetch` `/api/mfe` with manual `Cookie` + spoofed UA | PASS - 200, ticket found | -| WebSocket with custom `Origin` + `v1.api.smartspeaker.audio` | PASS - opened, subprotocol negotiated | -| Desktop bootstrap (households -> devices -> playbackExtended) | PASS - groups discovered, live `PLAYBACK_STATE_*` pushes | -| `pause` command | PASS - acknowledged | -| `play` command | PASS - acknowledged; live state `BUFFERING -> PLAYING`, then `pause` -> `PAUSED` | -| Token survives app restart (SecureStore) | PASS - after force-stop, restored token drove `/api/mfe` + full WS bootstrap (WebView cookie jar also still held the cookie, so this proves SecureStore holds a valid token, not that it is the only working source) | -| Ported client (`src/sonos/sonosClient.ts`) vs real Sonos | PASS - `getQueueResources`, `getFavoriteResources`, on-demand service discovery (serviceId 72711). `getGroups` returns a non-JSON 200 but is unused on desktop (groups come from the WS). | -| Session lifetime / silent refresh | open - watch whether the cookie slides or hard-expires at ~10 days | +| Check | Result | +| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Sonos sign-in in an embedded WebView | PASS - normal Okta flow (login.sonos.com) works, not blocked | +| Read HttpOnly `__Secure-next-auth.session-token` natively | PASS - visible via `@preeternal/react-native-cookie-manager`; cookie `expires` ~10 days out | +| RN `fetch` `/api/mfe` with manual `Cookie` + spoofed UA | PASS - 200, ticket found | +| WebSocket with custom `Origin` + `v1.api.smartspeaker.audio` | PASS - opened, subprotocol negotiated | +| Desktop bootstrap (households -> devices -> playbackExtended) | PASS - groups discovered, live `PLAYBACK_STATE_*` pushes | +| `pause` command | PASS - acknowledged | +| `play` command | PASS - acknowledged; live state `BUFFERING -> PLAYING`, then `pause` -> `PAUSED` | +| Token survives app restart (SecureStore) | PASS - after force-stop, restored token drove `/api/mfe` + full WS bootstrap (WebView cookie jar also still held the cookie, so this proves SecureStore holds a valid token, not that it is the only working source) | +| Ported client (`packages/core/src/sonos/sonosClient.ts`) vs real Sonos | PASS - `getQueueResources`, `getFavoriteResources`, on-demand service discovery (serviceId 72711). `getGroups` returns a non-JSON 200 but is unused on desktop (groups come from the WS). | +| Session lifetime / silent refresh | open - watch whether the cookie slides or hard-expires at ~10 days | ## Building locally (command-line tools only, no Android Studio) @@ -78,5 +80,5 @@ We chose local builds over EAS so source and signing keys stay on our machines. - Expo SDK 57 / RN 0.86 is New Architecture only. `@react-native-cookies/cookies` is unmaintained and unsupported there, so this uses `@preeternal/react-native-cookie-manager`. -- `src/sonos/` mirrors constants and helpers from `TrueTunes/src/main.ts`. It is meant to become the - shared `core` package once the spike passes. +- `packages/core/src/sonos/` mirrors constants and helpers from the desktop `apps/desktop/src/main.ts`. The desktop + does not use `packages/core` yet. From b85cbc4f3f7e1756b6b31fcdd79051d381485301 Mon Sep 17 00:00:00 2001 From: Joe Pitts Date: Fri, 9 Oct 2026 12:27:49 +0100 Subject: [PATCH 24/24] ci(desktop): publish only on desktop paths, not the shared lock or packages/ Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UMj5XGHLroJb9q68jXkw8B --- .github/workflows/desktop.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 3eee14b..9340a15 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -4,13 +4,13 @@ name: Desktop # Only desktop paths trigger it: without the filter, every mobile commit to main would publish a desktop release. # PRs also run it for server/ changes, because the server is linted with the desktop ESLint config. on: + # Publishing is narrower than validation: the root lock and packages/ change with mobile work too, and each + # push here ships a release. Desktop dependency changes always touch apps/desktop/package.json. Add packages/** + # once the desktop imports @truetunes/core. push: branches: [main] paths: - 'apps/desktop/**' - - 'packages/**' - - 'package.json' - - 'package-lock.json' - '.github/workflows/desktop.yml' pull_request: branches: [main, develop]