From cdf2c98f9fa882561991414975712841bd6d078d Mon Sep 17 00:00:00 2001 From: rays Date: Thu, 15 Oct 2026 15:24:25 +0000 Subject: [PATCH] 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 --- tests/ui/app.spec.js | 20 +++++++++++++++++++ web/CHANGELOG.md | 2 +- web/src/dialogs.ts | 47 ++++++++++++++++++++++++++++++++------------ web/src/items.ts | 34 ++++++++++++++++++++++++++------ 4 files changed, 83 insertions(+), 20 deletions(-) diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index f391f5c..9384dd8 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -2034,3 +2034,23 @@ test('a list seen before shows at once, and one not yet seen shows a spinner whi await expect(page.locator('#eps .listload')).toHaveCount(0); await page.unroute(/\/api\/(entries|feeds\/[^/]+\/entries)\?/); }); + +test('Currently Listening and the Directory show at once once seen, with a spinner the first time', async ({ page }) => { + // #248. Both answers held back a second and a half. + const slow = /\/api\/(directory$|entries\?filter=in_progress)/; + await page.route(slow, async route => { await new Promise(r => setTimeout(r, 1500)); await route.continue(); }); + // What the page fetched ahead when it loaded is let finish and then forgotten, and no more is + // fetched ahead, or the first visit would find the Directory already here, as it is meant to. + await page.waitForFunction(() => pageFetching.size === 0 && fetching.size === 0); + await page.evaluate(() => { window.preloadLists = () => {}; pages.clear(); }); + for (const [id, drawn] of [[':listening', '#listening .childrow, #listening .hint'], [':directory', '#directory > *:not(.listload)']]) { + await page.locator(`.place[data-id="${id}"]`).click(); + await expect(page.locator('.listload'), `${id}: waited for, and said so`).toBeVisible(); + await expect(page.locator(drawn).first()).toBeVisible({ timeout: 10_000 }); + await page.locator('.place[data-id=":all"]').click(); + await page.locator(`.place[data-id="${id}"]`).click(); + await expect(page.locator(drawn).first(), `${id}: drawn from what was kept`).toBeVisible({ timeout: 500 }); + await expect(page.locator('.listload')).toHaveCount(0); + } + await page.unroute(slow); +}); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index b99b3dc..ffee19a 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). +- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248). ### Changed diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 23f72d3..9a053aa 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -100,13 +100,23 @@ const KIND_WORD: Record={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']){ $('#epSearch').placeholder='Find a feed…'; - box.innerHTML='

Loading…

'; + box.innerHTML='
'; $('#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; } $('#epSearch').placeholder='Search items…'; @@ -284,19 +293,30 @@ async function renderListed(v: {title: string, icon: string, url?: string, blurb
${v.icon}

${v.title}

${v.blurb}
-

Loading…

`; +
`; $('#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?`

Nothing you are listening to mentions “${esc(S.q)}”.

` :'

Nothing in progress. Episodes you start and do not finish show up here.

'; 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']); } diff --git a/web/src/items.ts b/web/src/items.ts index 7e72d2c..5260bf2 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -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=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(); +const pageFetching=new Map>(); +function fetchPage(url: string): Promise{ + 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='

Loading…

'; } function renderEntries(){