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(){