/* ---------------- items ---------------- */ let loadSeq=0; type ListPage={total: number, entries: Entry[]}; /// The first page of each list opened or fetched ahead, by what it shows, so a list seen before is /// drawn the moment it is asked for and refreshed behind it (#247). The newest few dozen: a page is /// 50 items, about 100 KB as JSON. const lists=new Map(); const LISTS_KEPT=40; /// Lists on their way, so a click on one already being fetched ahead waits for that answer. const fetching=new Map>(); const listKey=(feed: string, filter=S.filter)=>[feed,filter,S.sort.col,S.sort.dir,S.q].join('\n'); function listUrl(feed: string, filter=S.filter, offset=0){ const p=new URLSearchParams({offset:String(offset),limit:String(LIMIT),filter,sort:S.sort.col,dir:S.sort.dir}); if(S.q) p.set('q',S.q); const sg=signalId(feed); return feed===':all' ? `/api/entries?${p}` : sg!==null ? `/api/signals/${sg}/entries?${p}` : `/api/feeds/${encodeURIComponent(feed)}/entries?${p}`; } function keepList(key: string, r: ListPage){ lists.delete(key); lists.set(key,r); if(lists.size>LISTS_KEPT) lists.delete(lists.keys().next().value!); } /// 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){ // Currently Listening and the Directory draw from their own address. const url=VIEWS[feed]?.url; if(url) return prefetchPage(url); 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){ // A Signal holds items of any feed, and an OPML's list its shows' (#283). const group=S.feeds.find(f=>f.id===feed)?.group; for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')||(group&&k.startsWith(group+'\n'))) lists.delete(k); } /// 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',':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); } /// `quiet` for a refresh nobody asked for (a live update): not a wait anyone sees, so not timed. async function loadEntries(append?: boolean, quiet?: boolean){ // The Directory lists feeds, and Currently Listening draws its own episodes. if(!S.feed || VIEWS[S.feed]?.url || S.feed===SIGNALS) return; const feed=S.feed, key=listKey(feed), asked=performance.now(), seq=++loadSeq; // From asking to the list drawn: what someone waits for after a click, under that click's trace // (#246). Once, when the list first shows: from what is kept, at once. let timed=quiet; const shown=()=>{ if(!timed){ timed=true; report('list_shown',{ms:Math.round(performance.now()-asked),feed,filter:S.filter}); } }; const kept=append ? undefined : lists.get(key); let spin=0; if(kept){ showList(kept,false,asked); shown(); } // Nothing to show yet: a spinner, if the answer is not back in a blink. else if(!append&&!quiet) spin=setTimeout(()=>{ if(seq===loadSeq) listLoading(); },120); let r: ListPage; try{ r=await (!append&&!kept&&fetching.get(key) || api(listUrl(feed,S.filter,S.offset))); }finally{ clearTimeout(spin); } // Only the latest list counts: marking everything read reloads the All tab, and clicking // Unread straight after could have that answer land last and replace the Unread one. if(seq!==loadSeq) return; if(!append) keepList(key,r); // The kept one was this already: drawn again it only flickers. if(kept&&JSON.stringify(kept)===JSON.stringify(r)) return; showList(r,!!append,asked); shown(); } /// A list's page into S and onto the screen. function showList(r: ListPage, append: boolean, asked: number){ S.total=r.total; for(const e of r.entries){ const w=readWrites.get(readKey(e)); if(w && !(w.done!==undefined&&w.donee.guid===S.sel||turned.has(e.guid); if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e)); if(!append && S.sel){ const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid)); entries=[...gone,...entries]; } S.entries = entries; renderEntries(); } /// A list being waited for: said with a spinner, so the click is seen to have been heard. function listLoading(box=$('#eps')){ if(!box) return; box.innerHTML='

Loading…

'; } function renderEntries(){ const box=$('#eps'); if(!box) return; const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''], sg=signalOf(S.feed); // An OPML's own row counts nothing: its shows do. const kids=f?S.feeds.filter(c=>c.group===f.id):[]; const unread=kids.length?kids.reduce((n,x)=>n+x.unread,0):f?f.unread:sg?sg.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0); $('#count').textContent= `${f?(f.title||f.id):sg?sg.name:v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`; const pane=$('#list'), top=pane?pane.scrollTop:0; box.innerHTML=''; if(!S.entries.length){ // Said only for a search, where the words typed are why; an empty feed or tab speaks for itself. if(S.q) box.innerHTML='

Nothing matches that search.

'; // A new Signal is empty while Jev reads, which would otherwise look broken. else if(sg&&S.filter==='all') box.innerHTML='

Nothing has come through yet. New items are read as your feeds are checked.

'; return; } for(const e of S.entries) box.appendChild(epEl(e)); syncPlayButtons(); 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})`; b.onclick=()=>{S.offset+=LIMIT;loadEntries(true)}; box.appendChild(b); } } function epEl(e: Entry){ // An item may carry several files. The row summarises the one you would act on -- // the playable one, else anything already downloaded, else the first -- and says how // many others there are; the pane below lists them all. const enc=e.enclosures.find(isPlayable) || e.enclosures.find(x=>x.path) || e.enclosures[0]; const has=!!(enc&&enc.path); const playable=isPlayable(enc); const others=e.enclosures.length-1; const el=document.createElement('div'); el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':''); el.dataset.guid=e.guid; const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join(''); const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):''); const name=entryName(e); // The title is a button, its click the row's, so Tab and a screen reader can open the item: // the row alone was a click target only a mouse could reach (#298). el.innerHTML=`
${[ num&&`${num}`, left&&`${left}`, others>0&&`+${others} more file${others===1?'':'s'}`, enc&&enc.deadair&&enc.deadair>0&&`${shorter(enc.deadair)}`, enc&&enc.deadair_pending&&DEAD_PENDING, enc&&enc.last_error&&enc.state==='error'&&`${esc(enc.last_error)}`, ].filter(Boolean).join('')}
${esc(feedName(e.feed_id))}
${enc?kindIcon(enc):''} ${enc&&!has?`
`:''}
${enc?mb(enc.length):''} ${dateOf(e.published)}
${playable?``: (enc&&!has?``:'')} ${has?``:''}
`; el.onclick=()=>selectEntry(e); $$('button[data-a]',el).forEach(b=>b.onclick=(ev: Event)=>{ev.stopPropagation();epAction(b.dataset.a!,e,el)}); return el; } /// Whether the browser can play it. Having a file is not the same as being playable: /// blog feeds put article images in enclosures, and an