From 3817dbebdd95339fbf8c960315861ce767e5eab4 Mon Sep 17 00:00:00 2001 From: rays Date: Thu, 10 Sep 2026 19:09:10 +0000 Subject: [PATCH] Stop the UI strobing during a scan 85 feeds meant 85 feed_done events, each rebuilding the sidebar and reloading the episode list. Bursts collapse into one refresh, per-feed "N new" toasts add up into one summary, and both lists keep their scroll position across a rebuild. A full scan now costs 11 feed refreshes instead of 85. Settings and Log move to a footer under the feed list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh --- PROGRESS.md | 15 +++++++++++++++ web/index.html | 41 +++++++++++++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 408da48..af550ab 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,6 +56,21 @@ and until now nothing set them. --- +## 2026-09-10 — Scanning stopped strobing + +A scan of 85 feeds fires 85 `feed_done` events, and the UI refreshed on every one: a full sidebar +rebuild plus an episode reload each time, which read as flicker. Bursts now collapse into one +refresh (500ms trailing), the per-feed "N new" toasts add up into a single summary, and both lists +keep their scroll position across a rebuild instead of jumping to the top. + +Measured over a full scan: 11 feed refreshes and 2 episode reloads in 45 seconds, down from one per +event. + +Settings and Log moved out of the header and toolbar into a footer under the feed list -- they are +housekeeping, not daily controls. The theme toggle stays in the header. + +--- + ## 2026-09-10 — Defaults and chrome * **All** is the default episode filter and the first tab; Unread, Downloaded and Flagged follow. diff --git a/web/index.html b/web/index.html index 49d3e89..00404f3 100644 --- a/web/index.html +++ b/web/index.html @@ -74,11 +74,15 @@ a{color:var(--accent)} } .iconbtn:hover{background:var(--raise);color:var(--fg)} .sidetools{display:flex;gap:6px;padding:0 12px 10px} -.sidetools button{ +.sidetools button,.sidefoot button{ flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px; padding:6px 8px;font-size:12.5px;color:var(--dim); } -.sidetools button:hover{border-color:var(--accent);color:var(--fg)} +.sidetools button:hover,.sidefoot button:hover{border-color:var(--accent);color:var(--fg)} +/* Settings and the log are housekeeping: they sit under the feeds, out of the way. */ +.sidefoot{display:flex;gap:6px;padding:8px 12px;border-top:1px solid var(--line);flex:none} +.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px} +.sidefoot i{font-style:normal;opacity:.75} .searchwrap{padding:0 12px 8px} input[type=search],input[type=text],input[type=number],select{ width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg); @@ -355,16 +359,18 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
@@ -495,7 +501,7 @@ async function loadFeeds(keepSel){ } function renderFeeds(){ const q=$('#feedFilter').value.trim().toLowerCase(); - const list=$('#feedlist'); list.innerHTML=''; + const list=$('#feedlist'); const top=list.scrollTop; list.innerHTML=''; const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q)); if(!shown.length){ list.innerHTML='

No feeds.

'; return; } @@ -534,6 +540,7 @@ function renderFeeds(){ if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); }; list.appendChild(el); } + list.scrollTop=top; } function selectFeed(id){ S.feed=id; S.offset=0; S.sel=null; S.q=''; @@ -664,12 +671,14 @@ async function loadEntries(append){ function renderEntries(){ const box=$('#eps'); if(!box) return; const c=$('#count'); if(c) c.textContent=`${S.total} episode${S.total===1?'':'s'}`; + const pane=$('#list'), top=pane?pane.scrollTop:0; box.innerHTML=''; if(!S.entries.length){ box.innerHTML=`

${S.q?'Nothing matches that search.':'Nothing here yet — try Scan now.'}

`; return; } for(const e of S.entries) box.appendChild(epEl(e)); + if(pane) pane.scrollTop=top; if(S.entries.length < S.total){ const b=document.createElement('button'); b.className='btn'; b.id='more'; b.textContent=`Load more (${S.entries.length} of ${S.total})`; @@ -1309,6 +1318,15 @@ if(localStorage.getItem('ipx.theme')) document.documentElement.dataset.theme=loc let sse; function connect(){ sse=new EventSource('/api/events'); + const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; + const refreshFeeds=soon(()=>loadFeeds(true)); + const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); }); + let fresh={}; + const tellNew=soon(()=>{ + const feeds=Object.keys(fresh), n=feeds.reduce((a,k)=>a+fresh[k],0); + if(n) toast(feeds.length===1 ? `${feeds[0]}: ${n} new` : `${n} new in ${feeds.length} feeds`); + fresh={}; + },900); sse.onmessage=m=>{ let ev; try{ ev=JSON.parse(m.data) }catch{ return } if(ev.ev==='progress'){ @@ -1322,16 +1340,19 @@ function connect(){ else if(ev.ev==='download_done'){ const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); if(bar) bar.classList.remove('live'); - toast('Downloaded '+ev.path.split('/').pop()); loadEntries(); loadFeeds(true); + toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds(); } else if(ev.ev==='download_error'){ const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); if(bar) bar.classList.remove('live'); - toast('Download failed: '+ev.msg,true); loadEntries(); + toast('Download failed: '+ev.msg,true); refreshEntries(); } - else if(ev.ev==='feed_done'){ if(ev.new) toast(`${ev.feed}: ${ev.new} new`); loadFeeds(true); if(ev.feed===S.feed) loadEntries(); } - else if(ev.ev==='feed_error'){ toast(ev.feed+': '+ev.msg,true); loadFeeds(true); } - else if(ev.ev==='scan_done'){ loadFeeds(true); if(S.feed) loadEntries(); } + else if(ev.ev==='feed_done'){ + if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); } + refreshFeeds(); if(ev.feed===S.feed) refreshEntries(); + } + else if(ev.ev==='feed_error'){ toast(ev.feed+': '+ev.msg,true); refreshFeeds(); } + else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); } }; sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); }; }