From d7aec4e810469a0381aa6273e8b1614c38a645f8 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 12 Aug 2026 21:02:19 -0400 Subject: [PATCH] fix(memory): bound visualizer PCM cache and feed episode cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The visualizer's PCM cache decoded the entire episode into RAM (22050 Hz mono s16 ~160 MB/hr of audio) and held it until stop() — a 3-hour episode pinned ~500 MB and long-form content hit 2.5 GB. The 4x decode also pulled the whole remote file even when only minutes were listened to. - audio-pcm-cache: sliding window around the playback position — the decode head caps at maxAheadSec (600s) ahead of the cursor, segments older than keepBehindSec (300s) are pruned, and the tail refills as playback advances. Steady state ~40 MB regardless of episode length; a backward seek past the window restarts a segment there (the existing seek-hole mechanism, no new failure mode). - feed: cap the full-parse episode cache at 1000 episodes/feed so archive-heavy subscriptions can't pin their entire history in RAM; the visible list stays bounded by the user's cache preference and fetch-more keeps working within the ceiling. - tests: pin the new head-cap and prune contracts (8/8 in audio-pcm-cache.test.ts; full suite 193 pass). Also includes the in-flight cleanup/refactor pass (cover-art resolve helper, page and comment tightening, ESLint config removal). --- .eslintrc.cjs | 11 - AGENTS.md | 2 +- bun.lockb | Bin 117147 -> 78110 bytes package.json | 3 - src/api/rss-parser.ts | 5 - src/components/EpisodeList.tsx | 246 +++++++++++ src/components/Shell.tsx | 1 - src/context/ThemeContext.tsx | 6 +- src/hooks/useAudio.ts | 58 ++- src/index.tsx | 1 - src/pages/Feed/FeedPage.tsx | 231 +++------- src/pages/MyShows/MyShowsPage.tsx | 645 ++++++++++++++-------------- src/pages/Search/SearchPage.tsx | 33 +- src/pages/Settings/SettingsPage.tsx | 3 - src/stores/activity.ts | 2 - src/stores/audio-nav.ts | 12 - src/stores/discover.ts | 8 - src/stores/download.ts | 136 +++--- src/stores/feed.ts | 40 +- src/stores/progress.ts | 15 - src/stores/search.ts | 7 - src/ui/command.tsx | 6 - src/ui/dialog.tsx | 12 - src/utils/app-persistence.ts | 6 - src/utils/audio-pcm-cache.ts | 96 ++++- src/utils/cavacore.ts | 2 - src/utils/event-bus.ts | 1 - src/utils/feeds-persistence.ts | 2 - src/utils/jsonc.ts | 6 - src/utils/keybinds-persistence.ts | 1 - src/utils/theme-observer.ts | 9 - src/utils/theme.ts | 25 +- tests/audio-pcm-cache.test.ts | 84 ++++ 33 files changed, 941 insertions(+), 774 deletions(-) delete mode 100644 .eslintrc.cjs create mode 100644 src/components/EpisodeList.tsx diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index edfd56a..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = { - root: true, - parser: "@typescript-eslint/parser", - plugins: ["@typescript-eslint"], - extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], - env: { - es2022: true, - node: true, - }, - ignorePatterns: ["dist", "node_modules"], -} diff --git a/AGENTS.md b/AGENTS.md index 20ba1e4..cebc811 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ - `bun tests/cavacore-smoke.ts` - Run specific native library smoke test ### Linting -- `bun run lint` - Run ESLint with TypeScript rules +- `bun run lint` - Run the TypeScript typecheck (`bun tsc --noEmit`) ## Code Style Guidelines diff --git a/bun.lockb b/bun.lockb index 3cf431d75af7c978277fa3201cd2d38f4b30460c..bad57d5f40e5866d9534bd5441ea2689e98e782d 100755 GIT binary patch delta 10519 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 K)$cv_0Q+w%Y~J7i delta 34249 zcmeHwbzD^6)9~J97g!|)gr&m*Tsjp61z7{d0y`F1xc%600U7` z6tFwM?s#Wzz{U8*`#zt~`_FTAcJ9oaGiT16IdShU=d8RZSVpSk*x6X08<^*s)wBHZ zDED?tJ<}Jy`e*#S^K;+JZZ6hU=uTPh-Mp0G;bleS4mLHix27qI#na^RvQiRaqrxQ7 zDMxd2&9uh>U!w{0xW2-f&*QZK<~zvR9Po4jk7ohcS;*s=0=58*ay`ImfM+Q4cr5|H z0o)9*JS9{XD+`lP09qaB2*A34hXKa)Zh#E{t3iGd;B;ARY*b1rFGU_VMxM-5@FJl= zGY~uisU_etz{Y^Jfsa*C1*`M`I{>W>cstOj$Q)=bz|kpS0@SPrlqLpR39K*z`~qlw zz)lRVR-z5^1{a|GG0^HfTV58QDJV=Hk`@7q;!ZjP)G{SquTQ)zJq(W3`gy&?aN#JPmLXR$BlV{rEwL zZk$M2O2XK99b z0pJg8a5mi0~JJDUBM5S&KNMdP6!xX_5ut?x)Lzb#em^IKWi=7PsvQN1n$w~ z6A&NR)H@iQ4;YhA0*sDJ1B}H?fb?*il?WKkkCP?G$isNN=%~0vbhWk>%^wID^(r&; z_ykA<*DDAM47qH;V7_mbEIwTlB1@5%0RyU(6$Sx|8RMejBq@j!sq#Y>cF{K%;x^K{m8=v^C97Nli{oh;ioK2O8@W%E$*ZxF=v# z`y9-`K-o+d3iK5#fWi)t7#|Uh5tRcp=8jI3M?jC{jbr2?afy;tNKngE#)__G)?UEq z#i%%0gj|xuYi~zewHEkT$}^x5-vo@-kBf_S!h&wtQ_fIirO2?uL&3m6MQ0%)dTTF} zaUU3f)m^|8n8@JafU#M70LJt#fHA)|U@Y)EYT@Obb! zHd&U)iw6NVPmK$WS&xK8g@>boGKdQdLmAv-x}OByh4S2ZJZO@vWWZ2%)>n5P&k1k^ zU>m?Oo;+TAz})~t6K3TBALGm#Xk#cpYd0{kkFjH0c}Kb?@|4)9_*5S6X(t{J+AixH zV9dCk!S;Z$W_f_IA7lc?04(sLYdF$}_Cz%B(G$T8?#ketEnsK$I4Puq3guR)5l=?Pr%1W{SJmBe)D03p~wW_g*x8 z5NI@PJA+jM=$ifjISyrufR8nt&ETjM3EIf>>`kZ3V)*|6jRv&nL+edc$WkLE;HR;X zQK6Awc~pvg98`!m1~Oof4G*MUHUKad6egD^N}^JDv5Y)CDn3k-mdKj~4UYz}BXOc6 zHX&4&3W;N*;-XS{>HTQAJS8nPit=1inmjq3rwEOd#l}E^G5s3`b_uj!#Au*Vq4NOR z(n)~PAn+s=Y!L%_yjDPa2hmY)3K%+2mKN}FVrd5$gSrLq(Q~SRF~7|qx+39WQOTfc z0FMnmW~SvyA)`4e04d_~!91P`;QfG6F&hKW6DWnxhtLYyfhT-u(Rk%YLdC&>Src|@ zZTb{!*uBE+bx~;t$6f;svu|%pIUKZg2Ve8`;~nX_gYG4LygQ>hwXjc_>e!&8TNbL6 zZxbsIYGu@JOkT-Tmu+dgZoQaSusi7c#PXnbH?8yvHsANYWg%|Stdj=6S53B6Cza1V z%=q_4_N%K)9CEZyxct`3cBOMOH1zqEig9cKDFHD-qkSh$byjWNyGOvVSssfdc@`&I zlopjL7Icl+*4iny=xtefWW-tPQCVZ}hHF-=zI}DaKC>-9o>;#7n5wrV&e}9&OI}Ez zJoD+;5BHnxEHhsP<{6jD1%N1^Jn z)nf3PFH?WEdahqV0|xu&$T5S<|dL zhs<=>5RTB(Dl8s9KYnrNk^I9~>s4C4R@g-jqN$KyYHe(D|F-)PyA zR!4f18{frr%A1jNrRJ>cgvDv;V)1)>P1O*y+@fbIrxp8d`mV8I?3D0Fc7bWG%~ssK zIYs5ZbKlI^!!?CP+kEtHFYa(ubz-xpgG{_v&-rpW<>-&f{$8TqOjLDtS+>k(*1=`OW_ccJ|>e(H6BH2 z81C(tBF)e#2pM^IV{pH7ePmjD&R)0jQkj}QecJmGedl~nDYLQP*!kF_sUZ``+1xS8 zICd)jn)CZ7>P7A3<@d840$L8Q>UJ_T^g`RW$z)CBA@#x4Wqs^VDAtI}PU?mvwd&;4 zT+^ds@RmzG-mQ8Ve#3irki_HRW|P{I%H7>Qv<+Lfadym+B`vO;x;JW=89%qW2eG?i z^mKmBw2Z|wo#QX+b?>t7UGx*@)MDL=mTf-=jeha-OOEWr1*ymVT^08O*x=La@i16@ zL96@mrf-i{R<-ghId6LLnPK0NcDegco{X||oT1WZ!q`OXx~^;tkfYTSiD_honv}Rm ziq)k;U4qARBTdwOiJs(Ubt#cWzJ|{Oq^E|I=tgG4XC_$+pQlJ|O)2q(9HJ@Z+bEH1 zH2sN1QFW(YpRb zJXxqK6_&tw;zPdF^c9-GNEJZV`uGZ?;LZBAWwvkEj`G7oV zBo(%4!Q=HurNRYp7fh*tLgpGviLT@g_&h>dm`M2&T{7ClpCHLX6Dd(nem0ScboF>V z7fStIV{cIqP%u$v5o8yAZz7+pFvWCYGpR@kCIdH+D3M7f-h?0d+DuB!#ZS>Wka(~X zk&-^0j!!xoc@v&wsX4kz+XDSE#6l|4f$eQc)hz=njIkF!LtPlgDkL6Sc-pglWc-{n|^3 zjpUN{QjsdG`bI)ZB{JL68$!rhA|-B+NfIf)U0d>y#GeQ!2|KB1Wm~#+m8jOZOy&Yl z6J|kdNrF5s@rHhAVJ{UafY+T;hpk&h76Q)_*8H7;C!o?Ll3g66qV2%LyvmfmS7e2Q zl<#Ci8aiUM${eM_oi>n(vi2qU8fa%*412Ubfy{QoFfWDA%cQomRHW9Pj%*bwq&kxY z&QfAL`4m2Pk&Z4>i1l+I$%FsP~Qf2fdD?Fvb#Xtv8^aE1XK`OLyX632m zYoHGUP1Qq)J+A|mvIAM#Q3^iN_L2%W0GDp@cVvN=ROH}F`$?JdQwZtUNh(?ZJnSAk zs^dH$k9U#^&A}P{$%$INFbwwVEEU?pGe9^sCP*NtrlCYw1b1Vo(IKBq@|FseBdhKFrb|SR1Z!XHA?FZ;KG_1>X_8VTf4OnkLQULb7_-z z_1wjYz(qf(uxtivlE>BUMf*V77xkj&M9N)wJopd#uuLi3Vwr44J&YIa^}wYD6^0F+ z#Kc`#4_p{r^nFFvFgn761NKlyZ{c{LyvW=f9kQ>rgJ>U6SSPB(Xba#$#*d?4KPR7VvFKpA#XRjMw*Kw%C66}t<7!k(Z+ z9yjt9l>mihfbRC*BBgG0H&&ujb^*$#A?0eI=vIKaQS=Qcx<4W11a8H#h)?B<28zyi zSI1j)lA&n7DuF|w>fjUTE5bfNbs$a5e6?r59ZoGm#+OP|s~!d}1ZI{7RgbqoVLUbC z)?C{WDoV}S+??9;fD2tGOO@ejVgzU(2QGBvEMq21S!*Y*VcmON2>)4mu?|H;L_fJ`m1IDg*nN-Z5`O=(mnx9oY@Gr z6!NaAyY_eBVpUkC76z2IU$3TI9V$37f$IZl*`k#xhaF+Ks$3U|w4rBU00?m;7WD=S zOA#O?oDNiHoSj6c@ecUPIB#kz!IL7@z^pM?VUaIzQ4=2*NFoJLm;(l74{yZVzwm zJwRa%Snu&kLf>6j2V6I-iFPY!zor9Emrfq4z{Cm(Df8w7g{IR{d4Zv*QB%|$o>kFU zDy7gDC|H=9`HH5%9p>RtadHCrK=D~iDF=OGxCH4a@fO*^V1^Zio~Q3EN(aghC>658 z+*|YyP*@6W!F!-+d$5Uo;9(b?NB5^WK%q+rl4$F#T>=!kf{ko9Dzdi>r04jiTp0|l zLw`$K4BWxLadm>}=4+~92yj7l)6zuSf$IZYenXgLwG|lv6Q@YlAFN}j;Z@st5EFH* zm~vMfa2+6zGFhSFt-XRtBWg$k?W%npxY)^57_JINgm!bN0TkHOFm2f%xi5eVE&NMb zuOWY`@Fd`JOXFc|@%IfOqr3PE-+{0L8P~;EWCw2b0F`1~@MtFk1;c)WiCSc~nlqW> zB2ZveaLiBxz;IOu!0S&KE2s;A>Gc6Hy#WATT#Ry9tKy3se8I=VG1DFZFD?eY0_#F4 zbf?7Ba7x|%Co~!Wm832%761nVlDZIMOGEJTumUhIP#0n)C?8{$x)7tKFiKDtV$2A= zmb$nY`OuLlIfOfPAx47+0-y&VDkyp|V7w4xK4=Q+Ld=KqacHM5F2)HU1OOF=Qqrav z70DSsVoVnffE9=VKm+0d@Zw@jmjHk`5dh1lZdi(a9)`1@2*J#MqRF0Ga`u0>BF~HqqHu@PScz5dbg5D5wBHMVA4D zjx=5^y2LDt!V#{x<+D?FRr_^%DRu#8_n!G8v5Nngfkko#FohD=>YN1Za>Jld%PZ z@r4ZX!8KID1`KTk7^`It7!7R$7z=F2U>m?_xE)};5To3I;kyt}KFVB~1YC?PH-?WG z3-AJrhIlh{H^8V+3K%D=K!)xI82UB}1~<83{IUom{Z3M%0nU^IsW-vMKR0?3UXX@*)DjCq;^ zjaZGr>Wm!IV6gr7D;%glw$=a866e2P;ZXhu18@n1HRCRAfTU=2xfc>}A;wB~20%}B z1HcP0R^-23;V5vag9`gG`2T5%13HES;Kjw*lpz3E!B7Ca7_6Xf&;U8brZFZA2f(rY zzggm-3S8>og&6fDG5F6b9Pt1D;1UOlqLw-h7!ANC)<B3I~P%%~A&y<^te_7(Mi# ztY9$xpVCqBhl2l+B@XDpp!}CB9FSiJz=~Aycw~$}tQ!7zy+e1x|6T9Qg}A`cfV<8C zlAz$_S9CrN!{r!K}JO3ZoJKq0~>z$(UeTsb)?E{uBT~|CT z_xPDAo1#$X_$Cg(LHjM2*_r#8c9vhVNnje1AZdyd;>*-(XByKGmb7Y zx;IwXyVLVd?Zkp-T^G2QU9oEy6x8F`?8yUl1JuWtm!|gmSZ?`n%fw~Z9+z~n8uIzz zP@nL76%se8J6y0uXx$|K{xZPsqYHUOv%d<@2|Q0!!p2V%;I)iPHpS2@#fZ( zO!KT6-&H*XdRuFIuep6SYFS&OQ)LGxa1@{58k}YL+P&P|WBNzwftwF@#^x&xEgdAz z+OfdXH+NZ#BKv~-4DF8#BWLtkXFFi6N%>RLCpVo=A1fSSmsp$|SRpAn z=nK0;;d|VswrFj2ZML@el#;4rZ?CG1XmxaL#O?)KUd1o68h2)c!Q)A0skd!3Pd$m* zd`acZBso_Tz4szCecfn!d$Q*4!uIR>BR>g{uLxXX&` zN?v0PceWp5TpAU;)?abKZjhGSXpu?6;72!RYL;go8ZQ(onY*uA_EKxw$c)dPLw4wn z*uA4yep$}K6#=NSfMEBr ztB(ifWNmBXR~58n!yCgrTZ;#t>NRv=nzmwFeX!P>sT{>;xr$@dN4suYwEnX9+%|fX zozvI-W2|s>sS5k(VmLDJ)%64u#dXsgw%UPXuf5vowfgLrHOHn{wjJj1)8X6Z`49X* z`*S@}!qppmNh8L3QP79siY1GlKIy%oEZWz6!kf7hysw8m8#1Hv7xvC`Y zV@|u2Tj^z=TYG-HKE1=1D_W_C3sd%ca11WxDsFpd{4L!OzL%?uqr=Id;@O!$R4i>h zLl2G+@Gh*V^gQv-t7h4W{6~Y{rapG`DHn87^lLvQDRXCR>m6<_s)+rK;-+1V{{j?T|v!G5JH;*QBT zEnD^ZspGi3k2doX&n9#9p5y3M2)^6Y->r5iRqB#C!+)c9i-xi z+L9o%p5DLXgw`Cz7r2V=E1vD!b#+b8ukDv^(vNWe+@fvq*KrrVJeA#Sq5i>Xn#P!& z{K}`E7yqd^FfqN@(l7fqVVhezvscxWX|8de7FKgOdN0zC^t5)t=RF?|WC|V>PN_3{ zT>HGH*H^8)g(ud{8ef*6F?i+3K|2q<=xCGw=4MpqXRWj(HxJ(~*!kHIqzzg4FdtyLrk zHl$t{Hn;Z6@wM5HZO0m(tMnV@H@@V9&{)Eov?)I4TiLwr`}XHNQnHywzr3LhzQSq+ zED$Sp7ink;+Q0Me(B9a6bJ%G07I(Wk9D3Z%W6HTJ#)h8jM9L2SJ9ytSzdcE~J*WIf zhmI|K^d7Q(UbLb_Fl2Fk9llj!ExyWCyjD+|ld90l|Dam=c$jXlVVhUKSUyQ4Rld~G z&oJaea%H=#ZwJhp7}(!BbLY*__mIZdY{v&)@!Hn5g4)D?^ml|(RS+d^cr>~k`x6#SRCq`a8vcARr+SrW5 z$R*+L;x|~unDyW&zQ$E-b9A=XR6W~HTSyZr@A$W^Ll4EcJrgw7ZynGj|LtUrhk>K! z4SDDOO66SOT#rM`uPHK=@4K(qoz#5!3BJb2MTrA@a}?vFCwmE=zODIeYB{~x{3o|> zT7F-3O-bd=fQ5U*RMxCMP_koFch{hTa~t?)Pi*njU%6?qlX%W?`$t;IPhSoySXBEg zf3x2#j$U|_WiJI!u$fQZcX1p!s*%7?ni$i~|>=DuP(Yf^9qR#SN9K|w`Bxs@Cr?=-q?NAv@+( zU)yFm!_@D5jo~F#*P8*x8CjD@m|amUTA(z#u>R=z5c!KeBBw&_q&JJ_S`G8p zy{f9KUZ2zZ%+_o>r9s`E5l??UzZ1TdZ?I_dQG4}T%>!!-6OU}2-@5e1%a%^}IErgn zYXJ+y>Qjao_8nCIkk>dDS|{Q78Gy{w2nRV%ZmtV)n}v)GzQp7RoYw@&W z16+IN1bDR?dfjC7+%;D=c(3sB+f|{|YSXnb?VA7d#OaMsT`R%igW{+KQmu^^`u6^c z4GwP}B!)Y=bZr}H!!`IeS8u^4|0DO6yAF8u!?t9!L1oXqimZtt$H(_NxQ{$_T{=+&&oZKeuyV>v5I69`>kPx=!Um#Rpfty@j7@%$6>D*@vU} zK3DPlv6XhsudH2B9G$Vagq+rU(lDdvk5?$$>-0FdG|ITr>BuTA>kU4$9VWetT4@rm z2*_<2254egK=_`(nnoZtT37>LeUr+uS-v5X!e@4baU7VhlOvAUU64e4;%DC{}Y_vcfu^c ztE|s}SNjFij0#>PXS_7 zbllZTVbiIT$wletn(v7roxh5s)GW=_H*F8T;7+(?+~p{K%vD^sJMWz2qwnzLAIth) zb!cN(qklcH+e;_2p+S3PH@$|A2$?-_rD@xh)4QC__&EE<_-u^@C~U^PZ~|sS67V zpMShHDNt46c<_Kzmu&0F38~XBr^dgSqI#vr1hH`0GL2}DbfXdDyJfkioC)Z~QT&Xn zI8FA}Wc#8`-AcZ_^n3T@dBI7Yc1y-Au{pJ7aGK@Jtw+~)ZgW2|GVR4Evtf31qm6bA z9Nb_1a;d6Mp11wbk_o2z&*`r;=>^4euHv7rj$f^|Sr#4$oqK9}^hou4lgjy)fFcy~6rF~5iMFzxP>jwQb);sf^TYjX^K!PWb~E9d^p z9@+u7=HA=eDukb`Xgjj>y^&%2(Y>lJWWPvXxw+-%GaZL4PEu~yX6F6$t{I)eXSZ;fljPAI*IIBCvtMzee!;HFiy5mo*3WwR zx&^=8xzb*H%LcuF@WH2}re(~VkPtGpxVfs+t<7NzVi(+eK-}_}IeC5Ww0y-nhaSZ} z>WxC>+;O6wYp}yvAODAb3SW=8z3=(H@=)6nnJ696rJH7r;OAz$YbHHBZ`YjAc>Q|h zjLCj+6Tir>$R`GVRl5E$JHX?e$&;?cp8CbdcopW=EcSIu)yaQd6Ni_%>)R7Cr> zTv4A>^2v8g_W}I{`y{>Rb00?89}Q5?AA(WFs;_(tf>c{>+%(z#W%cEw9Td~Y6PZOl z`lOq@8drL0%E9F8DY?UC_ji0rKa+X&KtT7ppCe-SPny}u**xy^&O01WyyY5v`f2|d zi?g>kjMmu~JZ*6C`nXS1u3gAKx%TLSg-g5o-qRYPIYA+N?f&4y2g6-OotLD|3)c?I z&WFg@xKj{w_i6v09L4XrihJx{#=D{Zym#g78K=U3bdo&m;gsoQ)ZFO8@^;&LI<>If zzN_|e>*>QfW)@{?&wNTKt$WnHrO(TiJ9hr#aR9Cx8!__#7KXMh<*RN=4H2mzu&$sRj-Fob#baMPD z{p~6a#d4*BB`&JVlT4oO**cwc7EK*;{(L9B<}Qaxa#n_zPFD zUq*o4D*5t)GbZ9Y?+KZomC<*ijy#4m=ZK2sg!F>!qZCO(ta4`C%t<@{D zRTk=giUStPR#82RTA5mL6o2I^eln|XT}a+(eX+}KjW`pF@`Fi*Tl#mOrXIFj-z#G3 z<*>?4AyX~Jy;oOJE9kVZS8AWc!;60$e%C2{QN*>~L&H~m;OPCv)vMOJ?#Xn;-OZ)# zU%K@SO?tgCCZK>CW?v6(5B1Y`X zzqtBVl|i#?+Z6vV&o;ejbHK~x$>KP$6pUSt&<*@nCjR}5fQ%m^){_{j!VrK_{_aICum~j-u}Da#%;?B8MgV%N{(WJ zt2jEa@438=bEkb6reb<7H|mJ`tko*sVKaJlFIu1+7G9?G!bqeSTx%xZZnNWx^OyMT ztsPV*=6SVD&%Sf~fF!o~XMJmlhuM<|xLQkG%xFCof%o;gyk+e<3+0X<(ICvsd?5I<6S^d5e5%Wnnww zV&|m5C;5d7rynvI|LN(~0n#ydYic$GBo2x{-DgnB)|m?YQ5I{lkgIt6koHGPFNiKH z)@Uz(ZlHP6Dr3C!w|h4ehLvueIRE=K(;+Uq zZ081-dJWC19qB*XGon)W+urY*g~2`L6T2%rKI14>;VR~@oK_L$kQ8{%^xU~)_tP>2 z`tO&fDus6l{U}l%74vP;wU7SEljpyUjW0UCVV|c%bFF<_N2e=h6?Z9EchRq7-4*%= zJ@i^om8*F5=ISZKb1lXXJow_<44pP5{s&eZ_+5#tnN&B$Sqt^z0bTF?EQrdC2 zbG>#>-H%K0U%KzF<8XayCo;sJ8o7ibS_9d z9Y9=Bvs3SoeKbPPy~XE%P8`L}xr#6Bnz+ryw<)6kgN~oFo=m;+u;q$<)k2^4Ge(BxwZL!eS%Yy#g^L=0c!F-) z{LUp`XVt$>SlUc>PH18irF-MW{2yaH2Ub`garR!?c9gK&ZP)N69n=#apJ{$4S0lgd zd!LNEZ_RI?k%|ghTXPhva}7>?aipgCqT21dT=z*W?~B*Fo6m`vJ>YA${nuqlxf4Cd zoK&nmd;3nkQ}D_z3hAm|=cW|Q^4yx8^zvG#4g%$oy!JjE#Ts12$`vl;MynG}HwqRP zy7x-wt$ml<{-I&r>Ex?}Lhc`1_VZ?R8`+XCd)ulEUO(%7{NYo(7dh$O&3v7dK40GR z*|hcBSiLy^YjPC}1B`e34ek8y!P9S*LwV(CJ!T%FTrju1K z9Di!iV@1YlN%{M^D+Bi&G1lFaJbYQ|3C)!TS$*si!X7E56y;n!dheO?A(!B0`1L2- zcKCF|UV=0OjnYm9odZix>I8&!R2}Z&lz*)-b%FaD*N^=fls@L3*URDdHnv?Q z{@338NIw>K-K<^ZwmyCw#rRuW_7c>(E$qG8{-)rbgURRFNg)P{auv!Sy{&)FPd^&> z%3fSARM|E4S^aFsS&Q>LCTxFT_R=?@K+&-_quF%B&@-O4n%sdxkE?ioc=Vl`oYC=v z99w-#Dt4(7dCgd8a$3h__`JU7^ye%9eCZldbRkZF;cvTeVl) zfAXF0JGVQ2rgi@D{T@Ty+iJH=dtusV%0sX23w+yWT`gWx`H?qz!r3sj8&wDBUkcN6 zhauPC>O)mQBD?BMKRXpxB@L?RJo8b9pzhC93rs6d$GLZTbVl)Ile$ByfS1tvbkLSd zk8Y{>&nPM1p*pD6U|-3l9v17nax6CDDxSjAd}QPO@|>_|^}Hpf-)_t#_qE7wd&Rd_ z-s^ey;T<}@Gzk7-yj*&xPIf@0P134_cL|!Ga&=~|I&;-bjd$>xoYAYmevMD2>?N3H zd8fEVu&Ku@`)vU#yzf`WS}Lhb&)q&GtxcxZLh}f0CV__bHV?!=?30 zPOjfyZ`LpU#DflVpE@v#QLhPC?>p<-nBxl`@6w9yRZ$YB+51VUTVJ;`3Zpk}6?*2S zzJu?t)G-f}Mf!Ih``ldg>{O9Xj}9HSmbl(X3!VG?bN+w?u3}TJV$+KgPw9;6JA0$U z;Fd&T{=>3EZ);29B4LGbKW>ixt(w$}H*JFjCzV!@DboKo-m&z)+BfBbJja<4=P!-i zsYNDWd$HZaj39012lLGdauOg5g1iaHk|=V@SwQHqTZDziM%m#eF!^nwbm_XHvYy9|31EJe&pbRtTUDfEWFGj{ezn4G8(T4xv{k z^p7$ln9MkF;4QQ(F9LJIg=0SQ@b}Y^$bbunYvkb%zYUN97yUMr{;jq%3gN<05^wRZ z%Gifd9N_RhBxN1;cf4?P!dnf1u>fcQ9jNr*LZkzY7mf+&6$l{eN4#hhc00_g4UhzY zGVC#!t_1-8xEE#SOgaNwg8C&YZ@E1|)U+bDQ(aFYO# z2#^Vo4Uhyd7GNAeGQeyA{AN4_APry&7&{YS2Ec59SpahZ<^W6umi zU^2iw0E`Xvwic#9_n@oLo!F+$5x^7oprE?|)c~~sgMd#0&H=#5Wj(+KfK34T0Q&*< z02Bcf0$c=0vra&12_qA7T^THX@FAzBow{? zAP-<6KrTQIS$H~Bu>Gk`!KdIQ)4H~=^TH~}~VxB~=0nqB}s0OElT0XPr$ z6@c;UQ2eeH|A?(Q@YDccfT#XQ@iO2m09OI9%0@x(jG(3Q}fJy;=0^s{SeDC`P;57g?WgP%E^K$^Ush`3< zRt_C<0pL6UhRYd%(*P#`jsqOUZhZu9@CTYWjT{4f7yuQY0zjj%@Dc!wwY~rtZ`f>@ z2AdF@4>5WdJ&PVj?_$N+O5KKgtQhNU)QKLyfrCa3++cHIvtfYV1wf1O9^(Ro_aVRo zfF}T0ix&W|0O}b)FTDf!4Dbm6-(r6Rz*ntk9JUm`Y(!(x_-_Dw@GgNdrv$+E2vmr1 zBLcu!#Q0GMz?$Gprvrc!3ic-S)J_1DiGel%&;!r~VAJB1iBlz}IACMItTC)1IJcuQtO1yoYbcfjZO*NLF;%cR2rK|B0UU4y zgKJV8K~`UAtpvB^&kJU~%wha5IZy9$rAmzSuyb{>bD;hfNDLBoqpd~>yL@8zg#(GF zowKK%6Hi7OT@)MP5TOn^s}Aflnt6M$8J`&GVCQJ(?#>%W243vRcXT0_U34Xmk`))7 zjoyJx%^~rg&!#tXvQ{486Yh3S9#B5-CuvqLcKcl!ql?FGuEs#+PSN1+*lEmrP*$BCAWz#5=O^ikM8iG+J~K>W&sxlNQ&+u>F+xYdxVSl=+TS zuMqS30a9C9e;eq>WO@!k1}1q z2y**nu@Spd?|y^RHJ2{^ctGU@Pr1Wl{qALF;tg4TRjk7<<`Wi|Yi7;}$k!sikzKA> zFm9l#Qbta^A~t#?qW39$)OWm6@1Q07Sxa5Pec)gMovKIPxgub)l3%WvQcT9vlt&pK zH*qW_P;E{wziME_Zup)Wb2L z$pPBP(TUfC>{sb*%x)Vzx<_bp%b7tpK@L(ZNlBetU1?y%?l1gVTORYn<-!t3;AjV- z2GbNM&{uNdd6O?I|CL|pFl+D&6>$0jHy#yFPJgoyqKGY^AuAJ`L>bxqI)tA#wc^P7 z7%;!y@qtw&Smti$h(SkX5IVqKNHOG)>vZVrk_FezU=y|D*TvWsUeYb4t4w8BLuyuK z6CcRjDluQ6OKz(&;A`lTXR4fF@2JmJ&af?* ziC(DS6OMLn7*Q}n7TysPQ^-!WVj_n;2lue08{BUuf5QDfQvJD@I7=R_6}wh}4jmZ7 zDn|^RQM7f2Nu!SEAZY=THm9q4PR=-)+$i}@s^1bDv-|pLlrA6S7`ZpFQEp{P&Ut3Q zcd;ag-!d>__XD=b+^hAqSz3H!y5Z#fTVf-2|KJ+<8Fr;sa#5o^o3saO7m&4(YON)0 z%{amI&8i>69U4>ZCADvhA(U-zi;dXLkY&cs>XFJeXB$(!B1hd8^Odd0V_=!K6}jt? z0Yec3$+Nee3EHK9uQX*$69rCm9_S0`?BCpM^mkp9u}yRtkXd&PX?--`$(Y?9I;wO} zo861Qe`~BulUf+D+esgKn9y1I&P^fo7H4{l3nlyA6T{w&G531A-fK-ykQ)6SrZDuw0Lr^b20jtPW_(dkoFPJ1Jn0P^4L*O;G!UI= zWla&|5r)aeDqSiL*9MiY$Suhr5Owg zt6FEZx1A~v(SijCu;3a%+QVFc^Mal-Odrg8gGeSPz7!j=TXOfiAv@-N-gP552$m(7 zjGjX1>7;SB!{HjS+jAfGB+g!z-}ubtb>{6Rd)JAL*loQvM0W<5))kMSL>|7toVS z*TA5_-gC(#ti*{a*c_FZLn1B6{yciZG*Jh)Rji*J=mmtX5KX2Rr0j<0yznEZUgjj# zKvE~Vn`0A+U8jR8m>sfQGUhIRld=0&V|jmBCb5eU2bYGez}ZcWA+H-OqwHLGq0G+L z>?Xb-aUr8G8AUncqJZ66oZTcDq%hgzBL=$xHM^BANSHxMoe zdF)o{?558k@u2+2V>eT$wuM%}zR)0Yft3Ul#O}LJZ6OUiNYm=vpwqBBv$LB_gM=CL z*iGHp?WsY6{(-u(ySlR*R8x7d33=>J^zuvvy{R^p39`WxNGCdO*=@N&g6l-c#BPJn zZs-jXj0niXZj{e%`3({m2w;$~d+oEkhEsW*@DYmHjbD+)ZY<8I123=}_On}$Q|c%a z*e(9q&B`0|u!jJ!+nG~&AkH0m>~R6?#^;m-dngYN6i}E#Ot5>Sg9x1pcC%*=u)C)> zN}}N$5gdSLF26QN5Uh4gm#{$+u0MFe8TgY-)TxzMH+UoCJBc}^}h61}wJ7fl5;4Z7|sUz%u?NrSu340F7png|- zk9bphm#H&2iaij4+1q^_pHM(^pr3f`nF#Fe?vRNIiobUa_S^(^2Y9L=DqJS{((@g= zM?5Te=s}0~OCCk*#!T$73+x8+AYq0M_5cQUD|t%div5;7qJiCP9wadA*?Be|*1&E% z&nIwshgD@yRluQC9XE)FO!QdFvj9dtVF=c58da zN-&1~#;aiyd_n=0re00_Jt)|7C)geIDRopH_5ccYFMW`}_>8q?kDy>T(?_#G0zttZ zQbF&xuc~kX5#x9Eyb5;beX1ZT6Z@SUyVpKQJRqb&AA5!cyBj}9T;YiyeENre#f&ao zZ3^7FUyF$8PCu-&M_jO*^;5>dtP1<3L66hK9>;9wPZ>vffjuUJ-S8hIOe_m}&|29e z0YHNO!LE@;pP*r_U`_*|MBpOW9ukHZT`1_60S+YWu^Q|F0aQVbp5SlxKn?b20Fb~4 zg4)<4H`v1iK*CJBv_t_XtuW^bG>ZN*tHO}Pp3K3XF#vgRfPfZfkK$lY6`&+kEF?0A zb+G3SfCOh6Fb>Z3P*6@~k0@X=frvfogFUo>%0#)2J?n!#wxBW3FP)v!*V*$w*pmz( z5158ytb#ovggxv4M6gQ0*LuH?Dpb3Iaep7Z*n>ydBNQ0p;2DEGVuU>`0VKFg&Fipk&V{VNY5B3DygA{;}OD3425doVGw6wt%J4 zdDz2D*mD_zK?KW9R0juFQRkbmM>SBHsODf#I$_Uj00}b&uxFpJr#Qf<43T2zLdIXx zS5Olid+JHU`3(f~KAOj#gTkKZz;BSS2c58IHZ)4uvsKtrA{r&XPo$Q!2U6d2X8r!C zv>YV3fQOgIdV~wP8jN+f!^eY$Ggmn8!r+}Oh6;O33wz)MI00XAIlA)LgIw4nDJb_) zi>d#-^6;d}`h`6&hCTVBQNo@m!=8=NDEZe_{98-F084!}@Oz1@#tDd; z#Q*M(-_>>Qn2&U! zUPvnbZph!G@(+_SuDT#J{vH5-S!XMtyRoPCu;-67Hs;?JtWG@k#2@zTlEyrLH~UYW zX#myie^`9}`z`~MD0Rva`!y20cc7z)TIo<-{_pPiyW9WnjsK?0EB?Lxe>dTePXjpf zHXOY4Z^t9WhsGB9eL(v6#<3?hO?VM;VMdGR@D&(LV0Iqha`p_TT>GJO+wAS+$!~bJ z2p>wX1Vss6XUKhTG@4(EBOn7js>Ksz`5Rx-;H`sU6+|g67pHi*o+rj>{P4jq&*6Go zLWaEULF3_OnUg)Sbnw% zVXUAIXtS5FYUrnLyVQvBK8*h8l!V08sDyY~YC>{oazaXqBqdQcHXhf)63~sOGRCDP z!+)5TBH2uEh?$%*EG0A{Q68HR22#5?d2)o@Co(Ng79XBs9}lONDQJtK_*BRdE=x(3 zQ0oJEQkpCl%1ld^#Y#dG;;mwS4bEQ_>Yo88~;b?B(TP?bIeZ?66UT{Oa0A2 zQ22|nl}1`TlL0xqdXyTffqZD7H!aLA_aI(PEOAK9ERV**~nZOukS zT&@J3+4FNP35#}2gK=7bX&T7L+(F@vPWg%IgryB_qCFEDOzT3*rVI%@P*CZaJ_F)p zIh86|4l+kH9QzN|RT-Z{bjHc@U)vLwW^^M^49LWxB-E>cd`Bh195nwPe31H2Y_Q*f zB;H$^HEk>^h9L`uDU;D5zQ05;75$X$RBxb2NQ`fHxMM&usHBH9NjRLy8;L_%A~k$bof3xc{5ud&KELx*K-V9* zpq7bFoXwb*D0b157JY`K%%y%$jJ=y_STXyC1f=XOL@3sziv?lfg~KjwBP8IOOjU&H z5A=K|p@%)@d|}9v5JxqJ8J7b(xFpj{E$o}9fJ9AbdH_Y2g~H*@Flou@YOxf$q~TuT zO#Xalr9-cF(PZjmQ3(*)O%9vhCj+d{ojQWC-k7>pGt&$e_}J^1a{k2P1h=HL5-^y z3{Ci8-AO(E`vEY)w6MC2~^Wq64>^mqhsqmXs>t9wrkl+^%L<){CmPYg}Loc5o z8GS>=kq0&K*>YD~s0UU!Pzy&lc#H;bH!Q|*5<_Pi8?qUGnbu&2rbcoz?ph8Ma!II} z8x?#W^dJB;1}y$4v+x{XnuNg&M<*y;o{}sNO@sDDtLc`e7NrIaBvT_=CUMPAvL~$c z8?sSSMuo(M5{fyCQ4n*ed~F?~wFzh03Stfg5$Hgf-kN#ghPmk@aIx<)16hhhJ}wn= zQE_Ji2^gAA%@EYQM|;bOvt))e9EzImsfbUBl!wS*jfra|S$r6!+T#}{m`X8g?Gfdm8$C2?&==r$yaYeuxtrQWDwfTt&frY1<>8`U(bZy6bJ7*HCA zk~rW{q{n>83Nq%7axbpjKtz2rONppEsF^z&U{8Gu415Yhu;S7J3YW#FM8T6odaOJp zB`pzWw-gAm*o1sXE5g{K2@B`1}xTrqaVZ^3R2(#0ZhQ+ zlA+Le7{ybm_H=B*LZi7!sF`8pGV0 zLakls8yI--bqX5xhv#G}`ycq+2a7+5sSysAukpTcczp675;QRjA9C?E3@r7jha7xm z>A<`|Gxu_%o^zN-;wCluO$m76Hy)>csZsIiI8244L!-+Qqa^ebXOk>ABHO2e{nXOG zF@Z_b_X~;13H0J$o;)ThRE`fZ7S2tK`y~aK_6vtH3}VL8?cW)I)oNlcLR zb{KV_sR_+i$}-{ijLgJLKl(t%CN!1tuRWwOM4Cnj7D5LY6&%)0f}>Fgg*6flCjOya asd6ASh1rA_zb$m34^htApJz|ZBK{AR_c?q3 diff --git a/package.json b/package.json index 5659716..1f7172e 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,6 @@ }, "devDependencies": { "@types/bun": "latest", - "@typescript-eslint/eslint-plugin": "^8.54.0", - "@typescript-eslint/parser": "^8.54.0", - "eslint": "^9.39.2", "typescript": "^5.9.3" }, "dependencies": { diff --git a/src/api/rss-parser.ts b/src/api/rss-parser.ts index c7fa0dd..5104dc6 100644 --- a/src/api/rss-parser.ts +++ b/src/api/rss-parser.ts @@ -100,24 +100,20 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis const epDescription = cleanField(getTagValue(item, "description")) const pubDate = new Date(getTagValue(item, "pubDate") || Date.now()) - // Audio URL + file size + MIME type from const enclosure = item.match(/]*url=["']([^"']+)["'][^>]*>/i) const audioUrl = enclosure?.[1] ?? "" const fileSizeStr = getAttr(item, "enclosure", "length") const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined const mimeType = getAttr(item, "enclosure", "type") || undefined - // Duration from const durationRaw = getTagValue(item, "itunes:duration") const duration = parseDuration(durationRaw) - // Episode & season numbers const episodeNumRaw = getTagValue(item, "itunes:episode") const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined const seasonNumRaw = getTagValue(item, "itunes:season") const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined - // Episode type & explicit const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType")) const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase() const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined @@ -135,7 +131,6 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis pubDate, } - // Only set optional fields if present if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber if (episodeType) ep.episodeType = episodeType diff --git a/src/components/EpisodeList.tsx b/src/components/EpisodeList.tsx new file mode 100644 index 0000000..9434477 --- /dev/null +++ b/src/components/EpisodeList.tsx @@ -0,0 +1,246 @@ +/** + * Shared list-row and preview components for the Feed and My Shows pages. + * + * Both pages render the same episode rows (marker + title, optional subtitle + * line, date/duration/selection/download meta line), "[Fetch More]" rows, and + * hovered-episode / fetch-more preview panes; the pages differ only in the + * props they pass (subtitle line, hint text, manual-mode wording). Extracted + * so the previously 3-4-level-nested render blocks run as flat named + * components. + * + * Anything that can change at runtime arrives as a signal getter: Solid + * components do not re-render, so only props that are called inside the + * component's own JSX stay reactive (focus, selection, download state). + */ + +import { Show } from "solid-js"; +import { format } from "date-fns"; +import type { RGBA } from "@opentui/core"; +import { useTheme } from "@/context/ThemeContext"; +import { useScrollIntoView } from "@/hooks/useScrollIntoView"; +import { NF_ICONS } from "@/utils/nerd-fonts"; +import { LoadingIndicator } from "@/components/LoadingIndicator"; +import type { Episode } from "@/types/episode"; + +// ── formatting helpers ────────────────────────────────────────────────────── +export const formatDate = (d: Date) => format(d, "MMM d, yyyy"); + +export const formatDuration = (s: number) => { + const mins = Math.floor(s / 60); + const hrs = Math.floor(mins / 60); + return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`; +}; + +// ── EpisodeRow ────────────────────────────────────────────────────────────── +export function EpisodeRow(props: { + /** The episode this row renders. */ + episode: Episode; + /** Optional second line under the title (podcast/show name). */ + subtitle?: () => string | undefined; + /** For index signal (row position). */ + index: () => number; + /** Focused row index in this list (-1 while the Fetch More row is + * focused, so no episode row draws the cursor). */ + focused: () => number; + /** Whether the current pane has keyboard focus. */ + active: () => boolean; + /** Whether this episode is selection-marked. */ + selected: () => boolean; + downloadLabel: () => string; + downloadColor: () => RGBA; + marker: () => string; + onMouseDown: () => void; +}) { + const { theme } = useTheme(); + const muted = () => theme.muted || theme.text; + const ref = useScrollIntoView(() => props.index() === props.focused()); + const isFocused = () => props.index() === props.focused(); + const bg = () => + isFocused() && props.active() + ? theme.primary + : isFocused() + ? theme.border + : undefined; + const fg = () => + isFocused() && props.active() + ? theme.surface + : isFocused() + ? theme.selectedListItemText ?? theme.text + : theme.text; + return ( + + + + {isFocused() ? props.marker() : " "} + + + {props.episode.episodeNumber ? `#${props.episode.episodeNumber} ` : ""} + {props.episode.title} + + + {/* podcast name on its own row — readable at a glance; the 50% + current pane fits it in full for typical names, and truncate + keeps the row one line tall either way */} + + + + {props.subtitle?.()} + + + + + + {formatDate(props.episode.pubDate)} + + + {formatDuration(props.episode.duration)} + + + + ● + + + + + {props.downloadLabel()} + + + + + ); +} + +// ── FetchMoreRow ──────────────────────────────────────────────────────────── +export function FetchMoreRow(props: { + /** Row index of the Fetch More button within the list. */ + index: () => number; + /** Focused row index. */ + focused: () => number; + /** True while the Fetch More row itself is focused. */ + onMore: () => boolean; + /** Whether the current pane has keyboard focus. */ + active: () => boolean; + isLoadingMore: () => boolean; + nerd: boolean; + marker: () => string; + onMouseDown: () => void; +}) { + const { theme } = useTheme(); + const ref = useScrollIntoView(props.onMore); + const bg = () => + props.index() === props.focused() && props.active() + ? theme.primary + : props.index() === props.focused() + ? theme.border + : undefined; + const fg = () => + props.index() === props.focused() && props.active() + ? theme.surface + : props.index() === props.focused() + ? theme.selectedListItemText ?? theme.text + : theme.text; + return ( + + {props.onMore() ? props.marker() : " "} + {props.nerd && ( + {NF_ICONS.more} + )} + } + > + [Fetch More] + + + ); +} + +// ── EpisodePreview ────────────────────────────────────────────────────────── +export function EpisodePreview(props: { + episode: () => Episode; + /** Optional line under the meta row (podcast/show name). */ + subtitle?: () => string | undefined; + author: () => string | undefined; + downloadLabel: () => string; + downloadColor: () => RGBA; + /** Page-specific action-hint line. */ + hint: () => string; +}) { + const { theme } = useTheme(); + const muted = () => theme.muted || theme.text; + return ( + + + + {props.episode().episodeNumber ? `#${props.episode().episodeNumber} ` : ""} + {props.episode().title} + + + + {formatDate(props.episode().pubDate)} + {formatDuration(props.episode().duration)} + + {props.downloadLabel()} + + + + {props.subtitle?.()} + + + by {props.author()} + + + + {props.episode().description?.slice(0, 400) ?? "No description available."} + {(props.episode().description?.length ?? 0) > 400 ? "…" : ""} + + + {props.hint()} + + ); +} + +// ── FetchMorePreview ──────────────────────────────────────────────────────── +export function FetchMorePreview(props: { + isLoadingMore: () => boolean; + fetchMoreMode: () => string; + /** Manual-mode explanation line ("across all feeds" vs "for this show"). */ + manualText: () => string; +}) { + const { theme } = useTheme(); + const muted = () => theme.muted || theme.text; + return ( + + + [Fetch More] + + + {props.isLoadingMore() + ? "Loading the next batch of episodes…" + : props.fetchMoreMode() === "auto" + ? "Auto mode: the next batch loads automatically at the bottom of the list." + : props.manualText()} + + + enter: load more · h back + + ); +} diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 0c18ebe..c57709b 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -169,7 +169,6 @@ export function Shell() { nav.backspaceCommand(); return; } - // printable char if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) { evt.preventDefault(); nav.appendCommand(evt.name); diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index ec09501..12820f7 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -10,7 +10,8 @@ import { generateSyntax, generateSubtleSyntax, } from "../utils/syntax-highlighter"; -import { resolveTerminalTheme, loadThemes } from "../utils/theme"; +import { resolveTerminalTheme } from "../utils/theme"; +import { getCustomThemes } from "../utils/custom-themes"; import { detectModeFromBackground } from "../utils/system-theme"; import { createSimpleContext } from "./helper"; import { @@ -175,7 +176,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ function init() { resolveSystemTheme(); - loadThemes() + getCustomThemes() .then((custom) => { setStore( produce((draft) => { @@ -187,7 +188,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ setStore("active", "catppuccin"); }) .finally(() => { - // Only set ready if not waiting for system theme if (store.active !== "system") { setStore("ready", true); } diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index a51f4f0..03ee1f3 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -297,6 +297,34 @@ function stopPolling(): void { // from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the // podcast cover to a temp file BEFORE playback starts, bounded to 3s. +/** Resolve cover art to a local path for mpv's --cover-art-files, per the + * call site's latency budget: + * "cache" — disk cache only (sync): resume paths must never wait on the + * network, so a miss plays artless and warms for next time. + * "bounded" — disk hit, else fetch capped at 1.2s: cold play needs the art + * at file LOAD, but a slow cover server must not stall audio. + * "await" — disk hit, else full (8s-bounded) fetch: boot restore preloads + * while feeds/progress load anyway, so the wait is free and the + * cover must be present when the file loads. + * fetchCoverArt already short-circuits on the disk cache, so "await" costs + * nothing on a warm cache. */ +async function resolveCoverArt( + coverUrl: string | undefined, + mode: "cache" | "bounded" | "await", +): Promise { + if (!coverUrl) return null; + if (mode === "cache") return cachedCoverPath(coverUrl); + if (mode === "bounded") { + const cached = cachedCoverPath(coverUrl); + if (cached) return cached; + return Promise.race([ + fetchCoverArt(coverUrl), + new Promise((resolve) => setTimeout(() => resolve(null), 1200)), + ]); + } + return fetchCoverArt(coverUrl); +} + async function play(episode: Episode): Promise { const b = ensureBackend(); setError(null); @@ -321,21 +349,15 @@ async function play(episode: Episode): Promise { // episode's own image (feeds added by URL may lack a channel cover). const downloadStore = useDownloadStore(); const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl; - const coverUrl = feed?.podcast.coverUrl ?? episode.imageUrl; // Cover art only applies at file LOAD (the runtime video-add fallback // never becomes an albumart track), so a cold-cache play must wait for // the fetch or play artless. Serve the disk cache synchronously; on a - // miss, await the single-flight fetch with a 1.2s cap (covers fetch in - // ~300ms typically) — past the cap, play bare and let the fetch warm - // the cache for next time. - let coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null; - if (coverUrl && !coverArtPath) { - const path = await Promise.race([ - fetchCoverArt(coverUrl), - new Promise((resolve) => setTimeout(() => resolve(null), 1200)), - ]); - if (path) coverArtPath = path; - } + // miss, await the bounded fetch (covers fetch in ~300ms typically) — + // past the 1.2s cap, play bare and let the fetch warm the cache. + const coverArtPath = await resolveCoverArt( + feed?.podcast.coverUrl ?? episode.imageUrl, + "bounded", + ); // Resume from saved progress if available and not completed const savedProgress = progressStore.get(episode.id); @@ -436,8 +458,10 @@ async function load(episode: Episode): Promise { // on feeds/progress at boot, so the bounded fetch (~300ms typical, // 8s worst case) is free. Falls back to the episode's own image when // the feed has no channel cover. - const coverUrl = feed?.podcast.coverUrl ?? episode.imageUrl; - const coverArtPath = coverUrl ? await fetchCoverArt(coverUrl) : null; + const coverArtPath = await resolveCoverArt( + feed?.podcast.coverUrl ?? episode.imageUrl, + "await", + ); const backendSnap = backend; backendSnap .preload(url, { @@ -612,8 +636,10 @@ async function switchBackend(name: BackendName): Promise { const podcastTitle = feed?.customName || feed?.podcast.title || ""; const url = useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl; - const coverUrl = feed?.podcast.coverUrl ?? ep.imageUrl; - const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null; + const coverArtPath = await resolveCoverArt( + feed?.podcast.coverUrl ?? ep.imageUrl, + "cache", + ); await backend.play(url, { startPosition: pos, volume: vol, diff --git a/src/index.tsx b/src/index.tsx index c127b4c..64d86c1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -42,7 +42,6 @@ if (cliArgs.version) { // ── CLI handlers ────────────────────────────────────────────────────── -/** Find the most recent episode across all feeds */ function findLatestEpisode( feeds: Feed[], ): { feed: Feed; episode: Episode } | null { diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index d84c385..c0b1c43 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -22,7 +22,6 @@ import { useDownloadStore } from "@/stores/download"; import { useAppStore } from "@/stores/app"; import { prefetchCoverArt } from "@/utils/cover-art"; import { DownloadStatus } from "@/types/episode"; -import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { @@ -33,14 +32,19 @@ import { } from "@/context/NavigationContext"; import { useAudio } from "@/hooks/useAudio"; import { on, off } from "@/utils/event-bus"; -import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts"; +import { supportsNerdFonts } from "@/utils/nerd-fonts"; import type { KeybindActionName } from "@/context/KeybindContext"; import type { Episode } from "@/types/episode"; import type { Feed } from "@/types/feed"; +import { + EpisodeRow, + FetchMoreRow, + EpisodePreview, + FetchMorePreview, +} from "@/components/EpisodeList"; import { LoadingIndicator } from "@/components/LoadingIndicator"; import { PaneRow } from "@/components/PaneRow"; import { TabListPane } from "@/components/TabPanel"; -import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useSelectionMarker } from "@/hooks/useSelectionMarker"; export const FeedPaneCount = 1; @@ -87,7 +91,6 @@ function FeedPage() { const app = useAppStore(); const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto"; const showFetchMore = () => feedStore.hasMoreAcrossAll(); - // Total navigable rows: episodes + the optional Fetch More row. const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0); const focus = () => nav.depthFocus(0); const focusedRow = () => @@ -103,7 +106,6 @@ function FeedPage() { const focusedItem = (): EpItem | undefined => focusedOnMore() ? undefined : episodes()[focusedEpIdx()]; const curLen = () => rowCount(); - const moreRef = useScrollIntoView(() => focusedOnMore()); const ensureFocus = () => { if (rowCount() > 0 && focus() >= rowCount()) @@ -129,12 +131,6 @@ function FeedPage() { }); // ── helpers ──────────────────────────────────────────────────────────────── - const formatDate = (d: Date) => format(d, "MMM d, yyyy"); - const formatDuration = (s: number) => { - const mins = Math.floor(s / 60); - const hrs = Math.floor(mins / 60); - return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`; - }; const downloadLabel = (id: string) => { switch (downloadStore.getDownloadStatus(id)) { case DownloadStatus.QUEUED: @@ -229,19 +225,6 @@ function FeedPage() { // ── render ────────────────────────────────────────────────────────────────── const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; - // Row highlight within the list. `active=true` only for the current pane. - const focusBg = (i: number, listFocus: number, active: boolean) => - i === listFocus && active - ? theme.primary - : i === listFocus - ? theme.border - : undefined; - const focusFg = (i: number, listFocus: number, active: boolean) => - i === listFocus && active - ? theme.surface - : i === listFocus - ? theme.selectedListItemText ?? theme.text - : theme.text; const currentLabel = () => `Feed · ${episodes().length}`; @@ -268,108 +251,38 @@ function FeedPage() { } > - {(item, index) => { - const fi = () => focusedEpIdx(); - const ref = useScrollIntoView(() => index() === fi()); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 0); - }} - > - - - {index() === fi() ? marker() : " "} - - - {item.episode.episodeNumber - ? `#${item.episode.episodeNumber} ` - : ""} - {item.episode.title} - - - {/* podcast name on its own row — readable at a glance; the - 50% current pane fits it in full for typical names, and - truncate keeps the row one line tall either way */} - - - {item.feed.customName || item.feed.podcast.title} - - - - - {formatDate(item.episode.pubDate)} - - - {formatDuration(item.episode.duration)} - - - - ● - - - - - {downloadLabel(item.episode.id)} - - - - - ); - }} + {(item, index) => ( + item.feed.customName || item.feed.podcast.title} + index={index} + focused={focusedEpIdx} + active={isActive} + selected={() => nav.isSelected(item.episode.id)} + downloadLabel={() => downloadLabel(item.episode.id)} + downloadColor={() => downloadColor(item.episode.id)} + marker={marker} + onMouseDown={() => { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + }} + /> + )} - episodes().length} + focused={focusedRow} + onMore={focusedOnMore} + active={isActive} + isLoadingMore={() => feedStore.isLoadingMore()} + nerd={nerd} + marker={marker} onMouseDown={() => { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(episodes().length, 0); }} - > - - {focusedOnMore() ? marker() : " "} - - {nerd && ( - - {NF_ICONS.more} - - )} - } - > - - [Fetch More] - - - + /> @@ -380,23 +293,23 @@ function FeedPage() { ); // ── preview pane: hovered-episode detail (or the Fetch More row) ────────── + const episodeHint = (item: EpItem) => + `enter: play · d: download${ + downloadStore.getDownloadStatus(item.episode.id) !== DownloadStatus.NONE + ? " · D: delete" + : "" + } · space: select · h back`; + const previewContent = () => ( <> - - - [Fetch More] - - - {feedStore.isLoadingMore() - ? "Loading the next batch of episodes…" - : fetchMoreMode() === "auto" - ? "Auto mode: the next batch loads automatically at the bottom of the list." - : "Load the next batch of older episodes across all feeds (Enter)."} - - - enter: load more · h back - + feedStore.isLoadingMore()} + fetchMoreMode={fetchMoreMode} + manualText={() => + "Load the next batch of older episodes across all feeds (Enter)." + } + /> {(item) => ( - - - - {item().episode.episodeNumber - ? `#${item().episode.episodeNumber} ` - : ""} - {item().episode.title} - - - - {formatDate(item().episode.pubDate)} - {formatDuration(item().episode.duration)} - - - {downloadLabel(item().episode.id)} - - - - - {item().feed.customName || item().feed.podcast.title} - - - by {item().feed.podcast.author} - - - - {item().episode.description?.slice(0, 400) ?? - "No description available."} - {(item().episode.description?.length ?? 0) > 400 ? "…" : ""} - - - - enter: play · d: download - {downloadStore.getDownloadStatus(item().episode.id) !== - DownloadStatus.NONE - ? " · D: delete" - : ""}{" "} - · space: select · h back - - + item().episode} + subtitle={() => + item().feed.customName || item().feed.podcast.title + } + author={() => item().feed.podcast.author} + downloadLabel={() => downloadLabel(item().episode.id)} + downloadColor={() => downloadColor(item().episode.id)} + hint={() => episodeHint(item())} + /> )} diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index f10fb03..c75fe53 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -15,11 +15,11 @@ */ import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js"; +import type { RGBA } from "@opentui/core"; import { useFeedStore } from "@/stores/feed"; import { useDownloadStore } from "@/stores/download"; import { useAppStore } from "@/stores/app"; import { DownloadStatus } from "@/types/episode"; -import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { @@ -31,16 +31,208 @@ import { } from "@/context/NavigationContext"; import { useAudio } from "@/hooks/useAudio"; import { on, off } from "@/utils/event-bus"; -import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts"; +import { supportsNerdFonts } from "@/utils/nerd-fonts"; import type { KeybindActionName } from "@/context/KeybindContext"; import type { Episode, DownloadedEpisode } from "@/types/episode"; import type { Feed } from "@/types/feed"; -import { LoadingIndicator } from "@/components/LoadingIndicator"; +import { + EpisodeRow, + FetchMoreRow, + EpisodePreview, + FetchMorePreview, + formatDate, +} from "@/components/EpisodeList"; import { PaneRow } from "@/components/PaneRow"; import { TabListPane } from "@/components/TabPanel"; import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useSelectionMarker } from "@/hooks/useSelectionMarker"; +// ── render components ──────────────────────────────────────────────────────── +// Depth-0 rows (subscribed shows, unsubscribed-show downloads) and their +// preview panes are My Shows-specific; episode rows/previews are shared with +// the Feed page (see EpisodeList.tsx). + +/** A subscribed-show row (depth 0). */ +function ShowRow(props: { + feed: Feed; + title: string; + index: () => number; + focused: () => number; + active: () => boolean; + marker: () => string; + wlScope: () => boolean; + wlInList: () => boolean; + onMouseDown: () => void; +}) { + const { theme } = useTheme(); + const muted = () => theme.muted || theme.text; + const ref = useScrollIntoView(() => props.index() === props.focused()); + const isFocused = () => props.index() === props.focused(); + const bg = () => + isFocused() && props.active() + ? theme.primary + : isFocused() + ? theme.border + : undefined; + const fg = () => + isFocused() && props.active() + ? theme.surface + : isFocused() + ? theme.selectedListItemText ?? theme.text + : theme.text; + return ( + + {isFocused() ? props.marker() : " "} + {props.title} + + ({props.feed.episodes.length}) + + + + {props.wlInList() ? "●" : "○"} + + + + ); +} + +/** An unsubscribed-show download row (depth 0, below the shows list). */ +function UnsubscribedRow(props: { + d: DownloadedEpisode; + index: () => number; + focused: () => number; + active: () => boolean; + marker: () => string; + downloadLabel: () => string; + downloadColor: () => RGBA; + onMouseDown: () => void; +}) { + const { theme } = useTheme(); + const ref = useScrollIntoView(() => props.index() === props.focused()); + const isFocused = () => props.index() === props.focused(); + const bg = () => + isFocused() && props.active() + ? theme.primary + : isFocused() + ? theme.border + : undefined; + const fg = () => + isFocused() && props.active() + ? theme.surface + : isFocused() + ? theme.selectedListItemText ?? theme.text + : theme.text; + return ( + + + + {isFocused() ? props.marker() : " "} + + + {props.d.episodeTitle ?? props.d.episodeId} + + + + {props.downloadLabel()} + + + + + + {props.d.podcastTitle ?? props.d.feedId} + + + + ); +} + +/** Depth-0 preview: the hovered subscribed show. */ +function ShowPreview(props: { + show: () => Feed; + title: () => string; + hint: () => string; +}) { + const { theme } = useTheme(); + const muted = () => theme.muted || theme.text; + const show = props.show; + return ( + + + {props.title()} + + + by {show().podcast.author} + + {show().episodes.length} episodes + + {show().podcast.description?.slice(0, 400) ?? "No description."} + + + {props.hint()} + + ); +} + +/** Depth-0 preview: the hovered unsubscribed-show download. */ +function UnsubscribedPreview(props: { + d: () => DownloadedEpisode; + downloadLabel: () => string; + downloadColor: () => RGBA; +}) { + const { theme } = useTheme(); + const muted = () => theme.muted || theme.text; + const d = props.d; + return ( + + + {d().episodeTitle ?? d().episodeId} + + {d().podcastTitle ?? d().feedId} + + + {formatDate(new Date(d().pubDate!))} + + + + {props.downloadLabel()} + + + + + Downloaded from episode search — the show is not subscribed. + + + enter: play · D: delete download · h: back + + ); +} + export const MyShowsPaneCount = 1; export function MyShowsPage() { @@ -67,7 +259,6 @@ export function MyShowsPage() { // entry drops out the moment the user subscribes to its show. const unsubs = () => downloadStore.getUnsubscribedDownloads(); - // Total depth-0 rows: subscribed shows + unsubscribed-show downloads. const depth0Count = () => shows().length + unsubs().length; const focusedShowIdx = () => @@ -107,7 +298,6 @@ export function MyShowsPage() { depth() >= 1 && !!drilledShowId() && feedStore.hasMoreEpisodes(drilledShowId()); - // Total navigable rows at depth 1: episodes + the optional Fetch More row. const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0); const focusedRow = () => rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1); @@ -121,7 +311,6 @@ export function MyShowsPage() { : Math.min(focusedRow(), Math.max(episodes().length - 1, 0)); const focusedEpisode = () => focusedOnMore() ? undefined : episodes()[focusedEpIdx()]; - const moreRef = useScrollIntoView(() => focusedOnMore()); const curLen = () => (depth() === 0 ? depth0Count() : rowCount()); @@ -155,12 +344,6 @@ export function MyShowsPage() { }); // ── helpers ───────────────────────────────────────────────────────────────── - const formatDate = (d: Date) => format(d, "MMM d, yyyy"); - const formatDuration = (s: number) => { - const mins = Math.floor(s / 60); - const hrs = Math.floor(mins / 60); - return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`; - }; const downloadLabel = (id: string) => { switch (downloadStore.getDownloadStatus(id)) { case DownloadStatus.QUEUED: @@ -323,14 +506,6 @@ export function MyShowsPage() { // ── render ────────────────────────────────────────────────────────────────── const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; - const focusBg = (i: number, lf: number, active: boolean) => - i === lf && active ? theme.primary : i === lf ? theme.border : undefined; - const focusFg = (i: number, lf: number, active: boolean) => - i === lf && active - ? theme.surface - : i === lf - ? theme.selectedListItemText ?? theme.text - : theme.text; const showTitle = (f: Feed) => f.customName || f.podcast.title; const currentLabel = () => @@ -349,18 +524,21 @@ export function MyShowsPage() { {(feed, index) => { const lf = () => nav.depthFocus(0); const ref = useScrollIntoView(() => index() === lf()); + const focused = () => index() === lf(); + const fg = () => + focused() + ? theme.selectedListItemText ?? theme.text + : theme.text; return ( - - {index() === lf() ? marker() : " "} - - {showTitle(feed)} + {focused() ? marker() : " "} + {showTitle(feed)} ({feed.episodes.length}) ); @@ -385,51 +563,28 @@ export function MyShowsPage() { } > - {(feed, index) => { - const lf = () => focusedShowIdx(); - const ref = useScrollIntoView(() => index() === lf()); - const wlScope = - app.state().preferences.autoDownloadScope === "whitelist"; - const wlInList = ( - app.state().preferences.autoDownloadWhitelist ?? [] - ).includes(feed.id); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 0); - }} - > - - {index() === lf() ? marker() : " "} - - - {showTitle(feed)} - - - ({feed.episodes.length}) - - - - {wlInList ? "●" : "○"} - - - - ); - }} + {(feed, index) => ( + + app.state().preferences.autoDownloadScope === "whitelist" + } + wlInList={() => + (app.state().preferences.autoDownloadWhitelist ?? []).includes( + feed.id, + ) + } + onMouseDown={() => { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + }} + /> + )} 0}> @@ -438,62 +593,21 @@ export function MyShowsPage() { - {(d, index) => { - // Rows continue after the shows list. - const rowIdx = () => shows().length + index(); - const lf = () => nav.depthFocus(0); - const ref = useScrollIntoView(() => rowIdx() === lf()); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(rowIdx(), 0); - }} - > - - - {rowIdx() === lf() ? marker() : " "} - - - {d.episodeTitle ?? d.episodeId} - - - - {downloadLabel(d.episodeId)} - - - - - - {d.podcastTitle ?? d.feedId} - - - - ); - }} + {(d, index) => ( + shows().length + index()} + focused={() => nav.depthFocus(0)} + active={isActive} + marker={marker} + downloadLabel={() => downloadLabel(d.episodeId)} + downloadColor={() => downloadColor(d.episodeId)} + onMouseDown={() => { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(shows().length + index(), 0); + }} + /> + )} @@ -509,98 +623,37 @@ export function MyShowsPage() { } > - {(ep, index) => { - const lf = () => focusedEpIdx(); - const ref = useScrollIntoView(() => index() === lf()); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 1); - }} - > - - - {index() === lf() ? marker() : " "} - - - {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} - {ep.title} - - - - - {formatDate(ep.pubDate)} - - - {formatDuration(ep.duration)} - - - - ● - - - - - {downloadLabel(ep.id)} - - - - - ); - }} + {(ep, index) => ( + nav.isSelected(ep.id)} + downloadLabel={() => downloadLabel(ep.id)} + downloadColor={() => downloadColor(ep.id)} + marker={marker} + onMouseDown={() => { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 1); + }} + /> + )} - episodes().length} + focused={focusedRow} + onMore={focusedOnMore} + active={isActive} + isLoadingMore={() => feedStore.isLoadingMore()} + nerd={nerd} + marker={marker} onMouseDown={() => { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(episodes().length, 1); }} - > - - {focusedOnMore() ? marker() : " "} - - {nerd && ( - - {NF_ICONS.more} - - )} - } - > - - [Fetch More] - - - + /> @@ -608,6 +661,30 @@ export function MyShowsPage() { ); // ── preview pane ─────────────────────────────────────────────────────────── + const episodeHint = (epId: string) => + `enter: play · d: download${ + downloadStore.getDownloadStatus(epId) !== DownloadStatus.NONE + ? " · D: delete" + : "" + }${ + app.state().preferences.autoDownloadScope === "whitelist" + ? (app.state().preferences.autoDownloadWhitelist ?? []).includes( + drilledShowId(), + ) + ? " · w: un-whitelist" + : " · w: whitelist" + : "" + } · space: select · h: back`; + + const showHint = (show: Feed) => + `enter/l: open · h: back · x: unsubscribe${ + app.state().preferences.autoDownloadScope === "whitelist" + ? (app.state().preferences.autoDownloadWhitelist ?? []).includes(show.id) + ? " · w: un-whitelist" + : " · w: whitelist" + : "" + }`; + const previewContent = () => depth() === 0 ? ( // depth 0 preview: hovered unsubscribed-show download, else the @@ -624,86 +701,34 @@ export function MyShowsPage() { } > {(show) => ( - - - {showTitle(show())} - - - by {show().podcast.author} - - - {show().episodes.length} episodes - - - {show().podcast.description?.slice(0, 400) ?? - "No description."} - - - - enter/l: open · h: back · x: unsubscribe - {app.state().preferences.autoDownloadScope === - "whitelist" - ? (app.state().preferences.autoDownloadWhitelist ?? - [] - ).includes(show().id) - ? " · w: un-whitelist" - : " · w: whitelist" - : ""} - - + show()} + title={() => showTitle(show())} + hint={() => showHint(show())} + /> )} } > {(d) => ( - - - {d().episodeTitle ?? d().episodeId} - - - {d().podcastTitle ?? d().feedId} - - - - - {formatDate(new Date(d().pubDate!))} - - - - - {downloadLabel(d().episodeId)} - - - - - Downloaded from episode search — the show is not - subscribed. - - - - enter: play · D: delete download · h: back - - + d()} + downloadLabel={() => downloadLabel(d().episodeId)} + downloadColor={() => downloadColor(d().episodeId)} + /> )} ) : ( // depth ≥1 preview: hovered episode (or the Fetch More row) <> - - - [Fetch More] - - - {feedStore.isLoadingMore() - ? "Loading the next batch of episodes…" - : fetchMoreMode() === "auto" - ? "Auto mode: the next batch loads automatically at the bottom of the list." - : "Load the next batch of older episodes for this show (Enter)."} - - - enter: load more · h back - + feedStore.isLoadingMore()} + fetchMoreMode={fetchMoreMode} + manualText={() => + "Load the next batch of older episodes for this show (Enter)." + } + /> } > - {(ep) => ( - - - - {ep().episodeNumber ? `#${ep().episodeNumber} ` : ""} - {ep().title} - - - - {formatDate(ep().pubDate)} - {formatDuration(ep().duration)} - - - {downloadLabel(ep().id)} - - - - - by {selectedShow()!.podcast.author} - - - - {ep().description?.slice(0, 400) ?? "No description available."} - {(ep().description?.length ?? 0) > 400 ? "…" : ""} - - - - enter: play · d: download - {downloadStore.getDownloadStatus(ep().id) !== - DownloadStatus.NONE - ? " · D: delete" - : ""} - {app.state().preferences.autoDownloadScope === "whitelist" - ? (app.state().preferences.autoDownloadWhitelist ?? []).includes( - drilledShowId(), - ) - ? " · w: un-whitelist" - : " · w: whitelist" - : ""}{" "} - · space: select · h: back - - - )} + {(ep) => ( + ep()} + author={() => selectedShow()?.podcast.author} + downloadLabel={() => downloadLabel(ep().id)} + downloadColor={() => downloadColor(ep().id)} + hint={() => episodeHint(ep().id)} + /> + )} + - - - ); + + ); return ( stack()[1]?.ctx ?? searchStore.query(); // ── input focusing ──────────────────────────────────────────────────────── - // `inputFocused` is true while the query input is being typed in. The Shell - // router yields keys to the while this is true; Escape (in Shell) - // sets it false so navigation resumes; `s` (search action) sets it true. - // - // The input's REAL focus is the source of truth for the flag: - // useInputFocusNav (the same hook the Settings forms use) flips - // `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag - // and the renderable in lockstep. That matters when the user clicks OFF the - // input: opentui's mouse dispatch auto-focuses the clicked target's nearest - // focusable ancestor (a pane scrollbox), blurring the input. The BLURRED - // event drops the flag, so the Shell router immediately resumes j/k/h - // instead of swallowing keys with no input to receive them — no more - // stuck "typing" state where Esc/j/k/s all do nothing. - // - // The depth stack still SEEDS the flag on transitions, since the query - // depth defaults to typing: re-entering depth 0 (h back from results, or a - // fresh mount) focuses the input; mounting at depth 1 (returning to the - // tab after a search) stays list-navigation — a stuck-on flag there would - // have the Shell yield j/k to a non-existent input. The depth STACK signal - // is also written by focus moves (setDepthFocus), so gate the seed on the - // depth VALUE via a memo: the effect must re-run only on an actual depth - // transition. Without the memo every j/k at the query depth re-focuses the - // input (undoing Escape), which keeps the recents list unreachable by - // keyboard. + // `inputFocused` tells the Shell router to yield keys to the query input. + // The input's REAL focus is the source of truth: useInputFocusNav flips + // the flag from the input's FOCUSED/BLURRED events, so clicking off the + // input drops it and the router resumes j/k/h — no stuck "typing" state. + // The depth stack only SEEDS it on transitions (re-entering depth 0 + // focuses the input; mounting at depth 1 stays list-nav), gated on the + // depth VALUE via a memo because setDepthFocus also writes the stack + // signal — without the memo every j/k at query depth re-focuses the input + // and strands the recents list. onMount(() => nav.setInputFocused(depth() === 0)); onCleanup(() => nav.setInputFocused(false)); const focusNavRef = useInputFocusNav(); diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx index 0d10c12..e8b4b25 100644 --- a/src/pages/Settings/SettingsPage.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -140,7 +140,6 @@ export function SettingsPage() { function open() { const d = depth(); if (d === 0) { - // drill into the focused section's items const id = focusedSection().id; nav.pushDepth({ kind: `settings:${id}`, @@ -206,7 +205,6 @@ export function SettingsPage() { function step(delta: number) { const d = depth(); if (d === 2) { - // editor: j/k nudges the value const it = editorItem(); if (it?.kind === "number" || it?.kind === "select") it.cycle?.(delta as -1 | 1); @@ -220,7 +218,6 @@ export function SettingsPage() { pane: PaneId; mode: NavMode; }) => { - // ignore actions meant for non-center panes if (data.pane !== DEPTH_CENTER_PANE) return; if (nav.activePane() !== DEPTH_CENTER_PANE) return; const handler = PAGE_ACTIONS[data.action]; diff --git a/src/stores/activity.ts b/src/stores/activity.ts index a2341ac..e4f55ec 100644 --- a/src/stores/activity.ts +++ b/src/stores/activity.ts @@ -10,7 +10,6 @@ import { createSignal } from "solid-js"; -/** Create activity store */ function createActivityStore() { const [count, setCount] = createSignal(0); const [labels, setLabels] = createSignal([]); @@ -64,7 +63,6 @@ function createActivityStore() { }; } -/** Singleton activity store */ let activityStoreInstance: ReturnType | null = null; export function useActivityStore() { diff --git a/src/stores/audio-nav.ts b/src/stores/audio-nav.ts index 21ac799..447e721 100644 --- a/src/stores/audio-nav.ts +++ b/src/stores/audio-nav.ts @@ -9,14 +9,12 @@ import { saveAudioNavToFile, } from "../utils/app-persistence"; -/** Source type for audio navigation */ export enum AudioSource { FEED = "feed", MY_SHOWS = "my_shows", SEARCH = "search", } -/** Audio navigation state */ export interface AudioNavState { /** Current source type */ source: AudioSource; @@ -28,14 +26,12 @@ export interface AudioNavState { lastUpdated: Date; } -/** Default navigation state */ const defaultNavState: AudioNavState = { source: AudioSource.FEED, currentIndex: 0, lastUpdated: new Date(), }; -/** Create audio navigation store */ function createAudioNavStore() { const [navState, setNavState] = createSignal(defaultNavState); @@ -56,12 +52,10 @@ function createAudioNavStore() { init(); return { - /** Get current navigation state */ get state(): AudioNavState { return navState(); }, - /** Update source type */ setSource: (source: AudioSource, podcastId?: string) => { setNavState((prev) => ({ ...prev, @@ -72,7 +66,6 @@ function createAudioNavStore() { persist(); }, - /** Move to next episode */ next: (currentIndex: number) => { setNavState((prev) => ({ ...prev, @@ -82,7 +75,6 @@ function createAudioNavStore() { persist(); }, - /** Move to previous episode */ prev: (currentIndex: number) => { setNavState((prev) => ({ ...prev, @@ -92,23 +84,19 @@ function createAudioNavStore() { persist(); }, - /** Reset to default state */ reset: () => { setNavState(defaultNavState); persist(); }, - /** Get current index */ getCurrentIndex: (): number => { return navState().currentIndex; }, - /** Get current source */ getSource: (): AudioSource => { return navState().source; }, - /** Get current podcast ID */ getPodcastId: (): string | undefined => { return navState().podcastId; }, diff --git a/src/stores/discover.ts b/src/stores/discover.ts index 47c18dc..a6e9384 100644 --- a/src/stores/discover.ts +++ b/src/stores/discover.ts @@ -85,7 +85,6 @@ function syncSubscriptionState( })); } -/** Create discover store */ export function createDiscoverStore() { const [selectedCategory, setSelectedCategory] = createSignal("all"); const [isLoading, setIsLoading] = createSignal(false); @@ -107,7 +106,6 @@ export function createDiscoverStore() { const refresh = async () => { setIsLoading(true); try { - // Skip if cache is still fresh const now = Date.now(); if (now - cachedAt < FEATURED_CACHE_TTL_MS) { syncSubscriptions(); @@ -131,7 +129,6 @@ export function createDiscoverStore() { cachedAt = now; setPodcasts(fetched); - // Reflect current feed-store subscriptions syncSubscriptions(); } catch { // Network failure — keep whatever we have (stale or empty) @@ -140,7 +137,6 @@ export function createDiscoverStore() { } }; - /** Get filtered podcasts by category */ const filteredPodcasts = () => { const category = selectedCategory(); if (category === "all") { @@ -155,7 +151,6 @@ export function createDiscoverStore() { }); }; - /** Subscribe to a podcast */ const subscribe = (podcastId: string) => { const podcast = podcasts().find((p) => p.id === podcastId); if (podcast) { @@ -168,7 +163,6 @@ export function createDiscoverStore() { ); }; - /** Unsubscribe from a podcast */ const unsubscribe = (podcastId: string) => { const podcast = podcasts().find((p) => p.id === podcastId); if (podcast) { @@ -180,7 +174,6 @@ export function createDiscoverStore() { ); }; - /** Toggle subscription */ const toggleSubscription = (podcastId: string) => { const podcast = podcasts().find((p) => p.id === podcastId); if (podcast?.isSubscribed) { @@ -207,7 +200,6 @@ export function createDiscoverStore() { }; } -/** Singleton discover store */ let discoverStoreInstance: ReturnType | null = null; export function useDiscoverStore() { diff --git a/src/stores/download.ts b/src/stores/download.ts index 91da978..2df9bc8 100644 --- a/src/stores/download.ts +++ b/src/stores/download.ts @@ -63,7 +63,62 @@ interface QueueItem { episodeTitle: string; } -/** Create download store */ +// ── post-download decoration ───────────────────────────────────────────────── +/** Write the podcast cover beside the audio so mpv's --cover-art-auto=exact + * picks it up for Now Playing art when the local file plays (same basename, + * .jpg extension — verified against mpv 0.41). curl, NOT fetch: Bun's fetch + * hangs in compiled binaries, so the shipped app never wrote this file. */ +function writeCoverArt(filePath: string, coverUrl: string): void { + const dot = filePath.lastIndexOf("."); + if (dot <= 0) return; + const coverPath = filePath.slice(0, dot) + ".jpg"; + Bun.spawn([ + "curl", + "-sS", + "--fail", + "-m", + "8", + "--max-filesize", + "2097152", + "-o", + coverPath, + coverUrl, + ]) + .exited.catch(() => {}); +} + +/** Tag the local file (codec-copy, no re-encode) so mpv's Now Playing + * metadata for local playback is title=episode, artist=podcast — the source + * streams carry no usable tags and macOS composes "title - artist" from + * exactly these fields. Atomic: ffmpeg writes a temp file, then renames + * into place. */ +function tagLocalFile( + filePath: string, + episode: Episode, + podcastTitle: string, +): void { + const tmp = `${filePath}.tag.mp3`; + Bun.spawn([ + "ffmpeg", + "-y", + "-i", + filePath, + "-c", + "copy", + "-metadata", + `title=${episode.title}`, + "-metadata", + `artist=${podcastTitle}`, + tmp, + ]) + .exited.then(async (code) => { + if (code !== 0) return; + const { renameSync } = await import("node:fs"); + renameSync(tmp, filePath); + }) + .catch(() => {}); +} + function createDownloadStore() { const [downloads, setDownloads] = createSignal< Map @@ -195,7 +250,6 @@ function createDownloadStore() { } } - /** Execute a single download */ async function executeDownload(item: QueueItem): Promise { const controller = new AbortController(); abortControllers.set(item.episodeId, controller); @@ -236,70 +290,23 @@ function createDownloadStore() { error: null, }); - // Write the podcast cover beside the audio so mpv's - // --cover-art-auto=exact picks it up for Now Playing art when the - // local file plays (same basename, .jpg extension — verified - // against mpv 0.41). curl, NOT fetch: Bun's fetch hangs in - // compiled binaries, so the shipped app never wrote this file. - // Falls back to the episode's own image when the feed has no - // channel cover (URL-added feeds). + // Decorate the local file: cover art + ID3 tags (see the + // module-level helpers above) — the source streams carry neither. + // Cover falls back to the episode's own image when the feed has + // no channel cover (URL-added feeds). const feedStore = useFeedStore(); const episode = feedStore.findEpisode(item.episodeId); - const coverUrl = - feedStore - .feeds() - .find((f) => f.id === item.feedId)?.podcast.coverUrl ?? - episode?.imageUrl; - if (coverUrl && result.filePath) { - const dot = result.filePath.lastIndexOf("."); - if (dot > 0) { - const coverPath = result.filePath.slice(0, dot) + ".jpg"; - Bun.spawn([ - "curl", - "-sS", - "--fail", - "-m", - "8", - "--max-filesize", - "2097152", - "-o", - coverPath, - coverUrl, - ]) - .exited.catch(() => {}); - } + const feed = feedStore.feeds().find((f) => f.id === item.feedId); + const coverUrl = feed?.podcast.coverUrl ?? episode?.imageUrl; + if (result.filePath && coverUrl) { + writeCoverArt(result.filePath, coverUrl); } - - // Tag the local file (codec-copy, no re-encode) so mpv's Now - // Playing metadata for local playback is title=episode, - // artist=podcast — the source streams carry no usable tags and - // macOS composes "title - artist" from exactly these fields. - // Atomic: ffmpeg writes a temp file, then renames into place. if (result.filePath && episode) { const podcastTitle = - feedStore.feeds().find((f) => f.id === item.feedId)?.podcast.title ?? + feed?.podcast.title ?? downloads().get(item.episodeId)?.podcastTitle; if (podcastTitle) { - const tmp = `${result.filePath}.tag.mp3`; - Bun.spawn([ - "ffmpeg", - "-y", - "-i", - result.filePath, - "-c", - "copy", - "-metadata", - `title=${episode.title}`, - "-metadata", - `artist=${podcastTitle}`, - tmp, - ]) - .exited.then(async (code) => { - if (code !== 0) return; - const { renameSync } = await import("node:fs"); - renameSync(tmp, result.filePath); - }) - .catch(() => {}); + tagLocalFile(result.filePath, episode, podcastTitle); } } } else { @@ -315,22 +322,18 @@ function createDownloadStore() { processQueue(); } - /** Get download status for an episode */ const getDownloadStatus = (episodeId: string): DownloadStatus => { return downloads().get(episodeId)?.status ?? DownloadStatus.NONE; }; - /** Get download progress for an episode (0-100) */ const getDownloadProgress = (episodeId: string): number => { return downloads().get(episodeId)?.progress ?? 0; }; - /** Get full download info for an episode */ const getDownload = (episodeId: string): DownloadedEpisode | undefined => { return downloads().get(episodeId); }; - /** Get the local file path for a completed download */ const getDownloadedFilePath = (episodeId: string): string | null => { const dl = downloads().get(episodeId); if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) { @@ -347,7 +350,6 @@ function createDownloadStore() { podcastFeedUrl?: string; } - /** Start downloading an episode */ const startDownload = ( episode: Episode, feedId: string, @@ -411,7 +413,6 @@ function createDownloadStore() { }); }; - /** Cancel a download */ const cancelDownload = (episodeId: string): void => { // Abort active download const controller = abortControllers.get(episodeId); @@ -432,7 +433,6 @@ function createDownloadStore() { saveDownloads().catch(() => {}); }; - /** Remove a completed download (delete file and metadata) */ const removeDownload = async (episodeId: string): Promise => { const dl = downloads().get(episodeId); if (dl?.filePath) { @@ -478,7 +478,6 @@ function createDownloadStore() { } }; - /** Get all downloads as an array */ const getAllDownloads = (): DownloadedEpisode[] => { return Array.from(downloads().values()); }; @@ -501,12 +500,10 @@ function createDownloadStore() { }); }; - /** Get the current queue */ const getQueue = (): QueueItem[] => { return queue(); }; - /** Get count of active downloads */ const getActiveCount = (): number => { return activeCount(); }; @@ -531,7 +528,6 @@ function createDownloadStore() { }; } -/** Singleton download store */ let downloadStoreInstance: ReturnType | null = null; export function useDownloadStore() { diff --git a/src/stores/feed.ts b/src/stores/feed.ts index d18bc04..6895ab9 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -49,6 +49,16 @@ const DEFAULT_REFRESH_INTERVAL_MINUTES = 30; * feeds) can't stall the renderer. */ const PARSE_CHUNK_SIZE = 5; +/** Hard ceiling on the in-memory full-parse cache per feed (newest first). + * The cache exists so fetch-more can page deeper without a refetch; without + * a ceiling a 5,000-episode archive pins tens of MB of Episode objects in + * RAM for the whole session (the old cache held EVERY parsed episode of + * every feed, contributing hundreds of MB for archive-heavy + * subscriptions). 1000 covers any realistic show's entire history — + * beyond it, hasMoreEpisodes flips false and the visible list is bounded + * by the user's cache preference as usual. */ +const MAX_CACHED_EPISODES_PER_FEED = 1000; + /** Yield to the event loop (task queue) so the renderer can paint between * parse chunks. MessageChannel instead of setTimeout/setImmediate because * bun:test fake timers trap those (feed-refresh/pagination tests run under @@ -214,7 +224,6 @@ async function mapWithConcurrency( return results; } -/** Create feed store */ function createFeedStore() { const [feeds, setFeeds] = createSignal([]); const [sources, setSources] = createSignal([ @@ -262,7 +271,6 @@ function createFeedStore() { saveFeeds(feeds()); }; - /** Get filtered and sorted feeds */ const getFilteredFeeds = (): Feed[] => { let result = [...feeds()]; const f = filter(); @@ -320,7 +328,6 @@ function createFeedStore() { return result; }; - /** Get episodes in reverse chronological order across all feeds */ const getAllEpisodesChronological = (): Array<{ episode: Episode; feed: Feed; @@ -341,7 +348,6 @@ function createFeedStore() { return allEpisodes; }; - /** Sort episodes in reverse chronological order (newest first) */ const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => { return [...episodes].sort( (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), @@ -384,8 +390,14 @@ function createFeedStore() { if (feedId) { // Cache the FULL parse — the bound is applied when reading, // not when writing, so a preference change takes effect - // without a refetch. - fullEpisodeCache.set(feedId, allEpisodes); + // without a refetch. Capped at MAX_CACHED_EPISODES_PER_FEED + // so an archive-heavy feed can't pin its entire history in + // RAM for the session (the visible window below is bounded + // by the user's preference regardless). + fullEpisodeCache.set( + feedId, + allEpisodes.slice(0, MAX_CACHED_EPISODES_PER_FEED), + ); } // Bound the visible window by the user's cache preference. @@ -410,7 +422,6 @@ function createFeedStore() { } }; - /** Check if a feed with this URL already exists */ const hasFeedByUrl = (feedUrl: string): boolean => { return feeds().some((f) => f.podcast.feedUrl === feedUrl); }; @@ -675,7 +686,6 @@ function createFeedStore() { }; scheduleNextRefresh(); - /** Remove a feed */ const removeFeed = (feedId: string) => { fullEpisodeCache.delete(feedId); episodeLoadCount.delete(feedId); @@ -706,7 +716,6 @@ function createFeedStore() { } }; - /** Update a feed */ const updateFeed = (feedId: string, updates: Partial) => { setFeeds((prev) => { const updated = prev.map((f) => @@ -717,7 +726,6 @@ function createFeedStore() { }); }; - /** Toggle feed pinned status */ const togglePinned = (feedId: string) => { setFeeds((prev) => { const updated = prev.map((f) => @@ -728,7 +736,6 @@ function createFeedStore() { }); }; - /** Add a source */ const addSource = (source: Omit) => { const newSource: PodcastSource = { ...source, @@ -742,7 +749,6 @@ function createFeedStore() { return newSource; }; - /** Update a source */ const updateSource = (sourceId: string, updates: Partial) => { setSources((prev) => { const updated = prev.map((source) => @@ -753,7 +759,6 @@ function createFeedStore() { }); }; - /** Remove a source */ const removeSource = (sourceId: string) => { // Don't remove default sources if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false; @@ -766,7 +771,6 @@ function createFeedStore() { return true; }; - /** Toggle source enabled status */ const toggleSource = (sourceId: string) => { setSources((prev) => { const updated = prev.map((s) => @@ -777,7 +781,6 @@ function createFeedStore() { }); }; - /** Get feed by ID */ const getFeed = (feedId: string): Feed | undefined => { return feeds().find((f) => f.id === feedId); }; @@ -792,7 +795,6 @@ function createFeedStore() { return undefined; }; - /** Get selected feed */ const getSelectedFeed = (): Feed | undefined => { const id = selectedFeedId(); return id ? getFeed(id) : undefined; @@ -853,6 +855,9 @@ function createFeedStore() { // is its own sync block). await yieldToUI(); cached = sortEpisodesReverseChronological(cached); + // Same ceiling as fetchEpisodes: the cache (and the paging + // window below) never exceeds MAX_CACHED_EPISODES_PER_FEED. + cached = cached.slice(0, MAX_CACHED_EPISODES_PER_FEED); fullEpisodeCache.set(feedId, cached); // Set current load count to match what's already displayed episodeLoadCount.set(feedId, feed.episodes.length); @@ -900,7 +905,6 @@ function createFeedStore() { } }; - /** True if any feed still has cached episodes beyond its loaded window. */ const hasMoreAcrossAll = (): boolean => { return feeds().some((f) => hasMoreEpisodes(f.id)); }; @@ -920,7 +924,6 @@ function createFeedStore() { } }; - /** Run the global auto-download pass (see runAutoDownload above). */ const runAutoDownloadNow = (): void => { runAutoDownload(); }; @@ -969,7 +972,6 @@ function createFeedStore() { }; } -/** Singleton feed store */ let feedStoreInstance: ReturnType | null = null; export function useFeedStore() { diff --git a/src/stores/progress.ts b/src/stores/progress.ts index a97f2cb..8fe08fe 100644 --- a/src/stores/progress.ts +++ b/src/stores/progress.ts @@ -64,16 +64,10 @@ function createProgressStore() { */ whenReady: () => progressInit, - /** - * Get progress for a specific episode. - */ get(episodeId: string): Progress | undefined { return progressMap()[episodeId]; }, - /** - * Get all progress entries. - */ all(): Record { return progressMap(); }, @@ -102,18 +96,12 @@ function createProgressStore() { persist(); }, - /** - * Check if an episode is completed. - */ isCompleted(episodeId: string): boolean { const p = progressMap()[episodeId]; if (!p || p.duration <= 0) return false; return p.position / p.duration >= COMPLETION_THRESHOLD; }, - /** - * Get progress percentage (0-100) for an episode. - */ getPercent(episodeId: string): number { const p = progressMap()[episodeId]; if (!p || p.duration <= 0) return 0; @@ -151,9 +139,6 @@ function createProgressStore() { persist(); }, - /** - * Clear all progress data. - */ clear(): void { setProgressMap({}); persist(); diff --git a/src/stores/search.ts b/src/stores/search.ts index ff0ee60..763e831 100644 --- a/src/stores/search.ts +++ b/src/stores/search.ts @@ -60,7 +60,6 @@ function saveScope(scope: SearchScope): void { } } -/** Create search store */ export function createSearchStore() { const feedStore = useFeedStore(); const [query, setQuery] = createSignal(""); @@ -167,7 +166,6 @@ export function createSearchStore() { } }; - /** Add query to history */ const addToHistory = (q: string) => { setHistory((prev) => { const updated = sanitizeHistory([q, ...prev]); @@ -176,13 +174,11 @@ export function createSearchStore() { }); }; - /** Clear search history */ const clearHistory = () => { setHistory([]); saveSearchHistoryToFile([]); }; - /** Remove single history item */ const removeFromHistory = (q: string) => { setHistory((prev) => { const updated = prev.filter((h) => h !== q); @@ -191,14 +187,12 @@ export function createSearchStore() { }); }; - /** Clear results */ const clearResults = () => { setResults([]); setQuery(""); setError(null); }; - /** Mark a podcast as subscribed in results */ const markSubscribed = (podcastId: string, feedUrl?: string) => { setResults((prev) => prev.map((result) => { @@ -262,7 +256,6 @@ export function createSearchStore() { }; } -/** Singleton search store */ let searchStoreInstance: ReturnType | null = null; export function useSearchStore() { diff --git a/src/ui/command.tsx b/src/ui/command.tsx index bf995d1..d47aec9 100644 --- a/src/ui/command.tsx +++ b/src/ui/command.tsx @@ -156,9 +156,6 @@ function init() { setRegistrations((arr) => arr.filter((x) => x !== results)); }); }, - /** - * Get all visible options. - */ get options() { return visibleOptions(); }, @@ -195,9 +192,6 @@ export function CommandProvider(props: ParentProps) { return {props.children}; } -/** - * Command palette dialog component. - */ function CommandDialog(props: { options: CommandOption[]; suggestedOptions: CommandOption[]; diff --git a/src/ui/dialog.tsx b/src/ui/dialog.tsx index ac31dac..ec2a22b 100644 --- a/src/ui/dialog.tsx +++ b/src/ui/dialog.tsx @@ -98,9 +98,6 @@ function init() { }) return { - /** - * Clear all dialogs from the stack. - */ clear() { for (const item of store.stack) { if (item.onClose) item.onClose() @@ -113,9 +110,6 @@ function init() { emit("dialog.close", {}) }, - /** - * Replace all dialogs with a new one. - */ replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) { if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable @@ -130,9 +124,6 @@ function init() { emit("dialog.open", { dialogId: "dialog" }) }, - /** - * Push a new dialog onto the stack. - */ push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) { if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable @@ -143,9 +134,6 @@ function init() { emit("dialog.open", { dialogId: "dialog" }) }, - /** - * Pop the top dialog from the stack. - */ pop() { if (store.stack.length === 0) return const current = store.stack.at(-1)! diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index ab79b0c..10eed17 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -62,7 +62,6 @@ const defaultState: AppState = { // ── App State (config.json) ───────────────────────────────────────────────── -/** Load app state from config.json */ export async function loadAppStateFromFile(): Promise { try { const cfg = await loadConfig(); @@ -88,7 +87,6 @@ export async function loadAppStateFromFile(): Promise { } } -/** Save app state to config.json */ export function saveAppStateToFile(state: AppState): void { updateConfig({ settings: state.settings, @@ -109,7 +107,6 @@ interface ProgressEntry { playbackSpeed?: number; } -/** Load progress map from JSON file */ export async function loadProgressFromFile(): Promise< Record > { @@ -145,7 +142,6 @@ export function saveProgressToFile(data: Record): void { const SEARCH_HISTORY_FILE = "search-history.json"; -/** Load search history from JSON file */ export async function loadSearchHistoryFromFile(): Promise { try { const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE)); @@ -159,7 +155,6 @@ export async function loadSearchHistoryFromFile(): Promise { } } -/** Save search history to JSON file (overwrite, no backup) */ export function saveSearchHistoryToFile(history: string[]): void { (async () => { try { @@ -178,7 +173,6 @@ export function saveSearchHistoryToFile(history: string[]): void { const AUDIO_NAV_FILE = "audio-nav.json"; -/** Load audio navigation state from JSON file */ export async function loadAudioNavFromFile(): Promise { try { const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE)); diff --git a/src/utils/audio-pcm-cache.ts b/src/utils/audio-pcm-cache.ts index 13ed908..6b29923 100644 --- a/src/utils/audio-pcm-cache.ts +++ b/src/utils/audio-pcm-cache.ts @@ -23,9 +23,16 @@ * pass over just that region) — earlier segments stay valid, mp3 decode of * the same file is deterministic so abutting segments agree. * - * Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min (~80 MB per 30 min), - * freed on stop(). 22050 Hz covers Nyquist 11 kHz, above the default 10 kHz - * high-cutoff of the visualizer's FFT config. + * Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min. The cache is a + * SLIDING WINDOW around the playback position — the decode pass stops + * once it is maxAheadSec ahead of the cursor and segments entirely older + * than keepBehindSec behind it are dropped (both re-filled/restarted on + * demand). Steady state is bounded by (maxAheadSec + keepBehindSec) of + * audio (~40 MB at the defaults) INDEPENDENT of episode length; the old + * whole-episode cache grew ~160 MB per hour of audio and hit 2.5 GB on + * long-form episodes. Fully freed on stop(). 22050 Hz covers Nyquist + * 11 kHz, above the default 10 kHz high-cutoff of the visualizer's FFT + * config. * * Downloads via ffmpeg's own http stack with reconnect flags, matching the * old reader; local files skip them (ffmpeg rejects http-only options for @@ -49,6 +56,24 @@ const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024; */ const CLOSE_IN_PLACE_GAP_SEC = 15; +/** + * Default decode-head budget: the ffmpeg pass pauses once it is this far + * ahead of the playback cursor. Bounds RAM (~26 MB of s16 at 22050 Hz) AND + * the network pull — the old cache decoded the whole episode at 4x, so a + * 3h show pinned ~500 MB (2.5 GB+ for long-form) and dragged the entire + * remote file even when only the first 10 minutes were listened to. At 4x + * pacing a refill costs ~150s of background decode, one ffmpeg spawn per + * ~10 min of playback. + */ +const DEFAULT_DECODE_AHEAD_SEC = 600; + +/** + * Default retention behind the cursor: decoded audio entirely older than + * this is dropped. Keeps pause/resume and small backward seeks instant + * without letting the window grow with playback time. + */ +const DEFAULT_KEEP_BEHIND_SEC = 300; + /** * Monotonically increasing generation counter. * Each startDecode() increments this; the read loop checks it to know @@ -73,6 +98,10 @@ export interface EpisodePcmCacheOptions { url: string; /** Sample rate (default: 22050) */ sampleRate?: number; + /** Decode-head budget in seconds ahead of the cursor (default: 600). */ + maxAheadSec?: number; + /** Retention in seconds behind the cursor (default: 300). */ + keepBehindSec?: number; } export class EpisodePcmCache { @@ -84,10 +113,15 @@ export class EpisodePcmCache { private activeSegment: Segment | null = null; readonly url: string; readonly sampleRate: number; + /** Sliding-window budgets (see maintainWindow). */ + readonly maxAheadSec: number; + readonly keepBehindSec: number; constructor(options: EpisodePcmCacheOptions) { this.url = options.url; this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE; + this.maxAheadSec = options.maxAheadSec ?? DEFAULT_DECODE_AHEAD_SEC; + this.keepBehindSec = options.keepBehindSec ?? DEFAULT_KEEP_BEHIND_SEC; } /** Whether an ffmpeg decode pass is currently running. */ @@ -238,11 +272,20 @@ export class EpisodePcmCache { * new segment at `sec` (seek into a hole / resume past cached audio). */ ensureDecodeAround(sec: number): void { + // Enforce the sliding-window budget first (head cap, prune, refill) + // so a resume or seek never leaves stale segments behind the cursor. + this.maintainWindow(sec); + // Data already on hand: nothing needed here; only keep the tail - // filling if the decode is idle and the episode is unfinished. + // filling if the decode is idle, the episode is unfinished, AND the + // head is inside its budget. A head-capped cache ("we're maxAheadSec + // ahead, enough decoded") is NOT a stalled decode — restarting it + // here would fight maintainWindow's cap on every resume call. if (this.covers(sec)) { if (this._decoding || this.decodeFinished) return; - this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec); + const end = this.coverageEndSec; + if (end >= sec + this.maxAheadSec) return; + this.startDecode(end > sec ? end : sec); return; } @@ -265,6 +308,48 @@ export class EpisodePcmCache { this.startDecode(Math.max(0, sec)); } + /** + * Sliding-window budget for the in-memory cache, driven by the live + * playback position. Runs on every read (the render loop is the only + * consumer that knows the cursor continuously) and on resume/seek: + * - capHead: the decode pass pauses once it is maxAheadSec ahead of the + * cursor (pauseDecode keeps the decoded data — a plain startDecode + * from the frontier refills it later). + * - prune: segments entirely keepBehindSec behind the cursor are + * dropped. A backward seek past the window restarts a segment there — + * the same mechanism as a seek into an undecoded hole, so no new + * failure mode. + * - topUp: when the cursor has outrun the head, restart the tail decode + * from the frontier (one ffmpeg spawn per maxAheadSec of playback). + * Together these bound memory to (maxAheadSec + keepBehindSec) of audio + * regardless of episode length. + */ + private maintainWindow(atSec: number): void { + const pos = Math.max(0, atSec); + + if (this._decoding && this.coverageEndSec >= pos + this.maxAheadSec) { + this.pauseDecode(); + } + + const keepFromSec = pos - this.keepBehindSec; + if ( + this.segments.some( + (seg) => seg.baseSec + seg.written / this.sampleRate < keepFromSec, + ) + ) { + this.segments = this.segments.filter( + (seg) => seg.baseSec + seg.written / this.sampleRate >= keepFromSec, + ); + } + + if (!this._decoding && !this.decodeFinished) { + const end = this.coverageEndSec; + if (end < pos + this.maxAheadSec) { + this.startDecode(Math.max(end, pos)); + } + } + } + /** * Read the PCM window ENDING at `atSec` of playback into `out` * (Int16 magnitudes widened to f64, the scale cavacore expects). @@ -275,6 +360,7 @@ export class EpisodePcmCache { */ readWindow(out: Float64Array, atSec: number): number { if (out.length === 0) return 0; + this.maintainWindow(atSec); const endIdx = Math.round(atSec * this.sampleRate); const startIdx = endIdx - out.length + 1; for (const seg of this.segments) { diff --git a/src/utils/cavacore.ts b/src/utils/cavacore.ts index 8126e91..f659540 100644 --- a/src/utils/cavacore.ts +++ b/src/utils/cavacore.ts @@ -52,7 +52,6 @@ const DEFAULTS: Required = { scalingMode: 0, }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any type CavaLib = { symbols: Record any>; close(): void; @@ -102,7 +101,6 @@ export class CavaCore { this.lib = lib; } - /** Number of frequency bars configured. */ get bars(): number { return this._bars; } diff --git a/src/utils/event-bus.ts b/src/utils/event-bus.ts index d8e4587..d9e7b6c 100644 --- a/src/utils/event-bus.ts +++ b/src/utils/event-bus.ts @@ -43,7 +43,6 @@ function createEventBus(): EventBusInstance { } handlers.get(event)!.add(handler as EventHandler); - // Return unsubscribe function return () => { this.off(event, handler); }; diff --git a/src/utils/feeds-persistence.ts b/src/utils/feeds-persistence.ts index 0728910..4908293 100644 --- a/src/utils/feeds-persistence.ts +++ b/src/utils/feeds-persistence.ts @@ -134,7 +134,6 @@ export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void { } })().catch(() => {}); } -/** Load sources from config.json */ export async function loadSourcesFromFile(): Promise { try { const cfg = await loadConfig(); @@ -144,7 +143,6 @@ export async function loadSourcesFromFile(): Promise { return null; } } -/** Save sources to config.json */ export function saveSourcesToFile(sources: T[]): void { updateConfig({ sources: sources as unknown as PodcastSource[] }); } diff --git a/src/utils/jsonc.ts b/src/utils/jsonc.ts index 1d8ae56..8c518ec 100644 --- a/src/utils/jsonc.ts +++ b/src/utils/jsonc.ts @@ -5,9 +5,6 @@ * and multi-line comments, which is useful for configuration files. */ -/** - * Remove JSONC comments from a string - */ function stripComments(jsonString: string): string { const comments = [ { pattern: /\/\/.*$/gm, replacement: "" }, @@ -23,9 +20,6 @@ function stripComments(jsonString: string): string { return result; } -/** - * Parse JSONC string into a JavaScript object - */ export function parseJSONC(jsonString: string): unknown { const stripped = stripComments(jsonString); return JSON.parse(stripped); diff --git a/src/utils/keybinds-persistence.ts b/src/utils/keybinds-persistence.ts index 8d6eb00..e3468a0 100644 --- a/src/utils/keybinds-persistence.ts +++ b/src/utils/keybinds-persistence.ts @@ -95,7 +95,6 @@ export async function copyKeybindsIfNeeded(): Promise { } } -/** Load keybinds from JSONC file */ export async function loadKeybindsFromFile(): Promise { try { const filePath = getConfigFilePath(KEYBINDS_FILE); diff --git a/src/utils/theme-observer.ts b/src/utils/theme-observer.ts index 02c76f3..e364668 100644 --- a/src/utils/theme-observer.ts +++ b/src/utils/theme-observer.ts @@ -9,23 +9,14 @@ import { emit } from "./event-bus" -/** - * Emit a theme reload event. - */ function emitThemeReload(): void { emit("theme.reload", {}) } -/** - * Emit a theme changed event. - */ export function emitThemeChanged(theme: string, mode: "dark" | "light"): void { emit("theme.changed", { theme, mode }) } -/** - * Emit a theme mode changed event. - */ export function emitThemeModeChanged(mode: "dark" | "light"): void { emit("theme.mode.changed", { mode }) } diff --git a/src/utils/theme.ts b/src/utils/theme.ts index cf43658..5ffbdce 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -1,28 +1,13 @@ /** - * Theme CSS Variable Manager - * Handles dynamic theme switching by updating CSS custom properties + * Terminal Theme Resolver + * Resolves the active theme (built-in, custom, or system-derived) to colors. */ import type { TerminalColors } from "@opentui/core"; import type { ThemeJson } from "../types/theme-schema"; -import { THEME_JSON } from "../constants/themes"; -import { getCustomThemes } from "./custom-themes"; import { resolveTheme as resolveThemeJson } from "./theme-resolver"; import { generateSystemTheme } from "./system-theme"; -/** - * Apply CSS variable data-theme attribute - */ -export function setThemeAttribute(themeName: string) { - if (typeof document === "undefined") return; - const root = document.documentElement; - root.setAttribute("data-theme", themeName); -} - -export async function loadThemes() { - return await getCustomThemes(); -} - export function resolveTerminalTheme( themes: Record, name: string, @@ -32,9 +17,5 @@ export function resolveTerminalTheme( if (name === "system" && system) { return resolveThemeJson(generateSystemTheme(system, mode), mode); } - const theme = themes[name] ?? themes.catppuccin; - if (!theme) { - return resolveThemeJson(THEME_JSON.catppuccin, mode); - } - return resolveThemeJson(theme, mode); + return resolveThemeJson(themes[name] ?? themes.catppuccin, mode); } diff --git a/tests/audio-pcm-cache.test.ts b/tests/audio-pcm-cache.test.ts index 1e1cb7e..019ba5a 100644 --- a/tests/audio-pcm-cache.test.ts +++ b/tests/audio-pcm-cache.test.ts @@ -278,3 +278,87 @@ test.skipIf(!hasFfmpeg)( }, { timeout: 20000 }, ); + +test.skipIf(!hasFfmpeg)( + "decode head caps at maxAheadSec ahead of the cursor — the cache is a window, not a whole-episode dump", + async () => { + const wav = tmpWav(); + writeSineWav(wav, 30); + const cache = new EpisodePcmCache({ + url: wav, + maxAheadSec: 4, + keepBehindSec: 2, + }); + try { + cache.startDecode(0); + // The 8s initial burst delivers the front of the file instantly. + await waitForCoverage(cache, 5); + + // Park the cursor at 0 and drive the cap (the render loop reads + // every frame; the cap applies on the first read past the head). + const out = new Float64Array(512); + for (let i = 0; i < 30 && cache.decoding; i++) { + cache.readWindow(out, 0); + await Bun.sleep(20); + } + + // Paused at the head budget (4s) + one 8s burst of slack — NOT + // decoded to the 30s EOF. + expect(cache.decoding).toBe(false); + expect(cache.coverageEndSec).toBeGreaterThanOrEqual(4); + expect(cache.coverageEndSec).toBeLessThanOrEqual(4 + 8 + 1); + expect(cache.decodeFinished).toBe(false); + + // A parked cursor keeps the cap: more reads must not restart + // the pass or grow the cache. + const cappedAt = cache.coverageEndSec; + for (let i = 0; i < 10; i++) { + cache.readWindow(out, 0); + await Bun.sleep(20); + } + expect(cache.decoding).toBe(false); + expect(cache.coverageEndSec).toBeLessThanOrEqual(cappedAt + 1); + } finally { + cache.stop(); + await Bun.$`rm -f ${wav}`.quiet(); + } + }, + { timeout: 20000 }, +); + +test.skipIf(!hasFfmpeg)( + "the window prunes segments behind the cursor as playback advances", + async () => { + const wav = tmpWav(); + writeSineWav(wav, 30); + const cache = new EpisodePcmCache({ + url: wav, + maxAheadSec: 4, + keepBehindSec: 2, + }); + try { + // Two segments: the back half [10, ~18] and, after the seek, + // the front [2, ~10]. + cache.startDecode(10); + await waitForCoverage(cache, 11); + cache.ensureDecodeAround(2); + await waitForCoverage(cache, 2.2); + expect(cache.covers(2.5)).toBe(true); + expect(cache.covers(10.5)).toBe(true); + + // Cursor advances past the front segment's end + keepBehind: + // the front must fall out of the window, the back must survive. + const out = new Float64Array(512); + for (let i = 0; i < 40 && cache.covers(2.5); i++) { + cache.readWindow(out, 13); + await Bun.sleep(25); + } + expect(cache.covers(2.5)).toBe(false); + expect(cache.covers(10.5)).toBe(true); + } finally { + cache.stop(); + await Bun.$`rm -f ${wav}`.quiet(); + } + }, + { timeout: 20000 }, +);