Currently Listening and the Directory open at once, or with a spinner (#248)
Both fetched on every visit and drew only when the answer came: Currently Listening took 1.3 s. fetchPage keeps their answers by address, as the item lists are kept, sharing a request already on its way; both draw from what is kept at once and again only if the refresh changed them, and show the spinner with nothing kept. They are fetched ahead with the lists when the page loads, and when the pointer or a finger reaches their row. Removing an episode from Currently Listening forgets its kept copy, which would otherwise show it again until the refresh. The preload loop takes a failed fetch as done instead of leaving an unhandled rejection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -100,13 +100,23 @@ const KIND_WORD: Record<string,string>={All:'feed',Podcasts:'podcast',Blogs:'blo
|
||||
/// categories, and a category has a page of its own, its subcategories along the top of its
|
||||
/// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across
|
||||
/// every page, and the search box looks through the lot. One fetch draws all of it.
|
||||
async function renderDirectory(url: string,box: HTMLElement){
|
||||
async function renderDirectory(url: string,box: HTMLElement,counted: (n: number)=>void){
|
||||
// Each visit starts at the front, as going back to a tab does.
|
||||
dirCat=null; dirSub=null; dirShow=null;
|
||||
dirError='';
|
||||
try{ dirRows=await api(url)||[]; }catch(e){ dirRows=[]; dirError=why(e); }
|
||||
// Drawn at once from what was kept or fetched ahead, then again only if the catalogue changed
|
||||
// (#248); with nothing kept, a spinner until it comes.
|
||||
const kept=pages.get(url);
|
||||
if(kept){ dirRows=kept; drawDirectory(box); counted(dirRows.length); }
|
||||
else listLoading(box);
|
||||
try{
|
||||
const fresh=await fetchPage(url)||[];
|
||||
if(kept&&JSON.stringify(kept)===JSON.stringify(fresh)) return;
|
||||
if(!box.isConnected) return;
|
||||
dirRows=fresh;
|
||||
}catch(e){ if(kept) return; dirRows=[]; dirError=why(e); }
|
||||
drawDirectory(box);
|
||||
return dirRows.length;
|
||||
counted(dirRows.length);
|
||||
}
|
||||
|
||||
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
|
||||
@@ -272,10 +282,9 @@ async function renderListed(v: {title: string, icon: string, url?: string, blurb
|
||||
syncTools(null);
|
||||
if(v===VIEWS[':directory']){
|
||||
$<HTMLInputElement>('#epSearch').placeholder='Find a feed…';
|
||||
box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>';
|
||||
box.innerHTML='<div id="directory"></div>';
|
||||
$('#count').textContent=v.title;
|
||||
const n=await renderDirectory(v.url!,$('#directory'));
|
||||
if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
|
||||
await renderDirectory(v.url!,$('#directory'),n=>{ if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`; });
|
||||
return;
|
||||
}
|
||||
$<HTMLInputElement>('#epSearch').placeholder='Search items…';
|
||||
@@ -284,19 +293,30 @@ async function renderListed(v: {title: string, icon: string, url?: string, blurb
|
||||
<div class="art">${v.icon}</div>
|
||||
<div class="meta"><h2>${v.title}</h2><div class="sub">${v.blurb}</div></div>
|
||||
</div>
|
||||
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
|
||||
<div class="childlist" id="listening"></div>`;
|
||||
$('#count').textContent=v.title;
|
||||
// The search box looks through these too, title and text, as it does a feed's items (#127).
|
||||
const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url!,$('#listening',box));
|
||||
if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`;
|
||||
await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url!,$('#listening',box),
|
||||
n=>{ if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`; });
|
||||
}
|
||||
|
||||
/// Currently Listening: episodes you started and have not finished, across every feed you
|
||||
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
|
||||
/// where you left off, not another way to browse. The one in the player pauses instead.
|
||||
async function renderListening(url: string,box: HTMLElement){
|
||||
let rows: Entry[]=[];
|
||||
try{ rows=(await api(url)).entries||[]; }catch{}
|
||||
async function renderListening(url: string,box: HTMLElement,counted: (n: number)=>void){
|
||||
// Drawn at once from what was kept or fetched ahead, then again only if it changed (#248): it
|
||||
// took over a second to answer. With nothing kept, a spinner until it comes.
|
||||
const kept: {entries?: Entry[]}|undefined=pages.get(url);
|
||||
if(kept){ drawListening(box,kept.entries||[]); counted((kept.entries||[]).length); }
|
||||
else listLoading(box);
|
||||
let fresh: {entries?: Entry[]};
|
||||
try{ fresh=await fetchPage(url); }catch{ if(kept) return; fresh={}; }
|
||||
if(kept&&JSON.stringify(kept)===JSON.stringify(fresh)) return;
|
||||
if(!box.isConnected) return;
|
||||
drawListening(box,fresh.entries||[]);
|
||||
counted((fresh.entries||[]).length);
|
||||
}
|
||||
function drawListening(box: HTMLElement,rows: Entry[]){
|
||||
box.innerHTML=rows.length?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>`
|
||||
:'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
||||
for(const e of rows){
|
||||
@@ -315,7 +335,6 @@ async function renderListening(url: string,box: HTMLElement){
|
||||
paintListenRow(el);
|
||||
box.appendChild(el);
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
/// One row's time left, progress and play button, taken from the player when it is the one in it.
|
||||
@@ -348,6 +367,8 @@ async function forget(e: Entry){
|
||||
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
||||
{method:'POST',body:JSON.stringify({secs:0})});
|
||||
}catch(err){ toast(why(err),true); }
|
||||
// The kept list still has it; drawn from that, it would show again until the refresh came.
|
||||
for(const k of [...pages.keys()]) if(k.startsWith(VIEWS[':listening'].url!)) pages.delete(k);
|
||||
if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,14 +21,30 @@ function keepList(key: string, r: ListPage){
|
||||
/// Fetches a list's first page ahead of a click: when the page has loaded, and when the pointer
|
||||
/// reaches a feed or a filter. Not one kept already or on its way.
|
||||
function prefetch(feed: string, filter=S.filter){
|
||||
// A folder's page lists its feeds, not items.
|
||||
if(VIEWS[feed]?.url||S.feeds.some(c=>c.group===feed)) return;
|
||||
// Currently Listening and the Directory draw from their own address; a folder's page lists its
|
||||
// feeds, not items.
|
||||
const url=VIEWS[feed]?.url;
|
||||
if(url) return prefetchPage(url);
|
||||
if(S.feeds.some(c=>c.group===feed)) return;
|
||||
const key=listKey(feed,filter);
|
||||
if(lists.has(key)||fetching.has(key)) return;
|
||||
const got: Promise<ListPage>=api(listUrl(feed,filter));
|
||||
fetching.set(key,got);
|
||||
got.then(r=>keepList(key,r),()=>{}).finally(()=>fetching.delete(key));
|
||||
}
|
||||
/// Other answers a view draws from, kept by address as the lists are (#248): Currently Listening
|
||||
/// and the Directory's catalogue. A request already on its way is shared, not sent twice.
|
||||
const pages=new Map<string,any>();
|
||||
const pageFetching=new Map<string,Promise<any>>();
|
||||
function fetchPage(url: string): Promise<any>{
|
||||
const on=pageFetching.get(url); if(on) return on;
|
||||
const got=api(url).then(r=>{ pages.delete(url); pages.set(url,r); if(pages.size>LISTS_KEPT) pages.delete(pages.keys().next().value!); return r; });
|
||||
pageFetching.set(url,got);
|
||||
got.finally(()=>pageFetching.delete(url)).catch(()=>{});
|
||||
return got;
|
||||
}
|
||||
const prefetchPage=(url: string)=>{ if(!pages.has(url)) fetchPage(url).catch(()=>{}); };
|
||||
|
||||
/// A feed changed (a scan, a read elsewhere): its kept lists, and every one of All Subscriptions,
|
||||
/// are fetched again when next wanted.
|
||||
function forgetLists(feed: string){
|
||||
@@ -37,8 +53,14 @@ function forgetLists(feed: string){
|
||||
/// What is likely to be opened next, fetched two at a time once the page is idle: All Subscriptions
|
||||
/// and the feeds with something unread, most first.
|
||||
function preloadLists(){
|
||||
const want=[':all',...[...S.feeds].filter(f=>f.unread>0&&!f.group).sort((a,b)=>b.unread-a.unread).slice(0,20).map(f=>f.id)];
|
||||
const next=()=>{ const f=want.shift(); if(!f) return; prefetch(f); (fetching.get(listKey(f))||Promise.resolve()).finally(next); };
|
||||
const want=[':all',':listening',':directory',...[...S.feeds].filter(f=>f.unread>0&&!f.group).sort((a,b)=>b.unread-a.unread).slice(0,20).map(f=>f.id)];
|
||||
const next=()=>{
|
||||
const f=want.shift(); if(!f) return;
|
||||
prefetch(f);
|
||||
const url=VIEWS[f]?.url, on=url ? pageFetching.get(url) : fetching.get(listKey(f));
|
||||
// On to the next when this one is in, failed or not: a failure here is nobody's to see.
|
||||
if(on) on.then(next,next); else next();
|
||||
};
|
||||
const go=()=>{ next(); next(); };
|
||||
'requestIdleCallback' in window ? (window as any).requestIdleCallback(go,{timeout:2000}) : setTimeout(go,300);
|
||||
}
|
||||
@@ -92,8 +114,8 @@ function showList(r: ListPage, append: boolean, asked: number){
|
||||
renderEntries();
|
||||
}
|
||||
/// A list being waited for: said with a spinner, so the click is seen to have been heard.
|
||||
function listLoading(){
|
||||
const box=$('#eps'); if(!box) return;
|
||||
function listLoading(box=$('#eps')){
|
||||
if(!box) return;
|
||||
box.innerHTML='<p class="listload" role="status">Loading…</p>';
|
||||
}
|
||||
function renderEntries(){
|
||||
|
||||
Reference in New Issue
Block a user