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:
@@ -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 expect(page.locator('#eps .listload')).toHaveCount(0);
|
||||||
await page.unroute(/\/api\/(entries|feeds\/[^/]+\/entries)\?/);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// 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.
|
/// 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.
|
// Each visit starts at the front, as going back to a tab does.
|
||||||
dirCat=null; dirSub=null; dirShow=null;
|
dirCat=null; dirSub=null; dirShow=null;
|
||||||
dirError='';
|
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);
|
drawDirectory(box);
|
||||||
return dirRows.length;
|
counted(dirRows.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
|
/// `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);
|
syncTools(null);
|
||||||
if(v===VIEWS[':directory']){
|
if(v===VIEWS[':directory']){
|
||||||
$<HTMLInputElement>('#epSearch').placeholder='Find a feed…';
|
$<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;
|
$('#count').textContent=v.title;
|
||||||
const n=await renderDirectory(v.url!,$('#directory'));
|
await renderDirectory(v.url!,$('#directory'),n=>{ if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`; });
|
||||||
if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$<HTMLInputElement>('#epSearch').placeholder='Search items…';
|
$<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="art">${v.icon}</div>
|
||||||
<div class="meta"><h2>${v.title}</h2><div class="sub">${v.blurb}</div></div>
|
<div class="meta"><h2>${v.title}</h2><div class="sub">${v.blurb}</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
|
<div class="childlist" id="listening"></div>`;
|
||||||
$('#count').textContent=v.title;
|
$('#count').textContent=v.title;
|
||||||
// The search box looks through these too, title and text, as it does a feed's items (#127).
|
// 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));
|
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')}`;
|
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
|
/// 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
|
/// 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.
|
/// where you left off, not another way to browse. The one in the player pauses instead.
|
||||||
async function renderListening(url: string,box: HTMLElement){
|
async function renderListening(url: string,box: HTMLElement,counted: (n: number)=>void){
|
||||||
let rows: Entry[]=[];
|
// Drawn at once from what was kept or fetched ahead, then again only if it changed (#248): it
|
||||||
try{ rows=(await api(url)).entries||[]; }catch{}
|
// 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>`
|
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>';
|
:'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
||||||
for(const e of rows){
|
for(const e of rows){
|
||||||
@@ -315,7 +335,6 @@ async function renderListening(url: string,box: HTMLElement){
|
|||||||
paintListenRow(el);
|
paintListenRow(el);
|
||||||
box.appendChild(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.
|
/// 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`,
|
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
||||||
{method:'POST',body:JSON.stringify({secs:0})});
|
{method:'POST',body:JSON.stringify({secs:0})});
|
||||||
}catch(err){ toast(why(err),true); }
|
}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']);
|
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
|
/// 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.
|
/// reaches a feed or a filter. Not one kept already or on its way.
|
||||||
function prefetch(feed: string, filter=S.filter){
|
function prefetch(feed: string, filter=S.filter){
|
||||||
// A folder's page lists its feeds, not items.
|
// Currently Listening and the Directory draw from their own address; a folder's page lists its
|
||||||
if(VIEWS[feed]?.url||S.feeds.some(c=>c.group===feed)) return;
|
// 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);
|
const key=listKey(feed,filter);
|
||||||
if(lists.has(key)||fetching.has(key)) return;
|
if(lists.has(key)||fetching.has(key)) return;
|
||||||
const got: Promise<ListPage>=api(listUrl(feed,filter));
|
const got: Promise<ListPage>=api(listUrl(feed,filter));
|
||||||
fetching.set(key,got);
|
fetching.set(key,got);
|
||||||
got.then(r=>keepList(key,r),()=>{}).finally(()=>fetching.delete(key));
|
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,
|
/// A feed changed (a scan, a read elsewhere): its kept lists, and every one of All Subscriptions,
|
||||||
/// are fetched again when next wanted.
|
/// are fetched again when next wanted.
|
||||||
function forgetLists(feed: string){
|
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
|
/// 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.
|
/// and the feeds with something unread, most first.
|
||||||
function preloadLists(){
|
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 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); (fetching.get(listKey(f))||Promise.resolve()).finally(next); };
|
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(); };
|
const go=()=>{ next(); next(); };
|
||||||
'requestIdleCallback' in window ? (window as any).requestIdleCallback(go,{timeout:2000}) : setTimeout(go,300);
|
'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();
|
renderEntries();
|
||||||
}
|
}
|
||||||
/// A list being waited for: said with a spinner, so the click is seen to have been heard.
|
/// A list being waited for: said with a spinner, so the click is seen to have been heard.
|
||||||
function listLoading(){
|
function listLoading(box=$('#eps')){
|
||||||
const box=$('#eps'); if(!box) return;
|
if(!box) return;
|
||||||
box.innerHTML='<p class="listload" role="status">Loading…</p>';
|
box.innerHTML='<p class="listload" role="status">Loading…</p>';
|
||||||
}
|
}
|
||||||
function renderEntries(){
|
function renderEntries(){
|
||||||
|
|||||||
Reference in New Issue
Block a user