The page fetched the Signals' unread counts only with the feed list, so reading an item, here or in its own feed, left its Signal's badge as it was, and so did Jev adding one. The Apple Signal said 2 unread over a page with none. The page now fetches them again after a read or unread lands, and on a new signal_hits event, which the judge sends once a pass for each person it found items for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
505 lines
27 KiB
TypeScript
505 lines
27 KiB
TypeScript
/* ---------------- 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<string,ListPage>();
|
|
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<string,Promise<ListPage>>();
|
|
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; 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){
|
|
// A Signal holds items of any feed.
|
|
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')) 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.done<asked)) e.read=w.read;
|
|
}
|
|
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
|
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
|
// tab -- would drop the item you have open the moment reading it took it off the filter.
|
|
// Keep it until you pick a different one; the next refresh after that no longer protects it.
|
|
// The items turned past on the way to it are kept too, so a swipe back still finds them.
|
|
const kept=(e: Entry)=>e.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='<p class="listload" role="status">Loading…</p>';
|
|
}
|
|
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);
|
|
const unread=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='<p class="empty">Nothing matches that search.</p>';
|
|
// A new Signal is empty while Jev reads, which would otherwise look broken.
|
|
else if(sg&&S.filter==='all') box.innerHTML='<p class="empty">Nothing has come through yet. Jev reads new items as your feeds are checked.</p>';
|
|
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);
|
|
el.innerHTML=`
|
|
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
|
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
|
<button class="fl${e.flagged?' on':''}" data-a="flag" aria-pressed="${!!e.flagged}" title="${
|
|
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
|
<div class="body">
|
|
<span class="t${name.derived?' notitle':''}">${esc(name.text)}</span>
|
|
<div class="line">${[
|
|
num&&`<span>${num}</span>`,
|
|
left&&`<span>${left}</span>`,
|
|
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
|
|
enc&&enc.deadair&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
|
|
enc&&enc.deadair_pending&&DEAD_PENDING,
|
|
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
|
|
].filter(Boolean).join('<span class="dot"></span>')}</div>
|
|
</div>
|
|
<span class="fd">${esc(feedName(e.feed_id))}</span>
|
|
<div class="file">
|
|
${enc?kindIcon(enc):''}
|
|
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
|
|
</div>
|
|
<span class="size">${enc?mb(enc.length):''}</span>
|
|
<span class="date">${dateOf(e.published)}</span>
|
|
<div class="rowacts">
|
|
${playable?`<button class="iconbtn" data-a="play" title="Play" aria-label="Play">${ICON.play}</button>`:
|
|
(enc&&!has?`<button class="iconbtn" data-a="get" title="Download this ${
|
|
enc.state==='skipped'?kindOf(enc):'file'}">${ICON.download}</button>`:'')}
|
|
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
|
|
</div>`;
|
|
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 <audio> element pointed at a JPEG
|
|
/// is just a broken player.
|
|
function isPlayable(x: Enclosure|undefined){
|
|
if(!x || !x.path) return false;
|
|
const m=(x.mime||'').toLowerCase();
|
|
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
|
if(m) return false;
|
|
// No declared type: fall back to the file's extension.
|
|
return /\.(mp3|m4a|m4b|aac|ogg|oga|opus|flac|wav|mp4|m4v|mov|webm|mkv)$/i
|
|
.test((x.path||x.url||'').split('?')[0]);
|
|
}
|
|
|
|
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
|
|
function kindOf(enc: {mime?: string|null, url?: string}){
|
|
const m=(enc.mime||'').toLowerCase();
|
|
if(m.startsWith('image/')) return 'image';
|
|
if(m.startsWith('video/')) return 'video';
|
|
if(m.startsWith('audio/')) return 'audio';
|
|
if(m.includes('pdf')) return 'pdf';
|
|
if(m.includes('torrent')) return 'torrent';
|
|
const ext=(enc.url||'').split('?')[0].split('.').pop();
|
|
return (ext && ext.length<=5) ? ext.toLowerCase() : 'file';
|
|
}
|
|
|
|
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
|
|
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
|
|
/// either way keeps the column lined up; the words are in its tooltip.
|
|
function kindIcon(enc: Enclosure){
|
|
const k=kindOf(enc);
|
|
const i=(<Record<string,string>>{audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent})[k]||ICON.file;
|
|
const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
|
|
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
|
|
: ['',k];
|
|
return `<span class="kind${cls}" title="${esc(label)}" aria-label="${esc(label)}">${i}</span>`;
|
|
}
|
|
|
|
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
|
|
const feedName=(id: string|null|undefined): string=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):(id??''); };
|
|
|
|
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
|
|
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's
|
|
/// name, then its feed and date: "(untitled)" fifty times down a list said nothing about any of
|
|
/// them. `derived` marks a name that is not a title, which the list sets as text, not heading.
|
|
/// Feed HTML as plain words. An inert document: nothing in it loads or runs, where a detached
|
|
/// element fetches its images. A line break or paragraph's end is a space, or "2010)<br>Recorded"
|
|
/// read "2010)Recorded".
|
|
const plainText=(html?: string|null)=>html
|
|
? (new DOMParser().parseFromString(html.replace(/<(br|\/p|\/div|\/li|\/h\d)\b[^>]*>/gi,' $&'),'text/html').body.textContent||'')
|
|
.replace(/\s+/g,' ').trim() : '';
|
|
function entryName(e: Partial<Entry>): {text: string, derived: boolean}{
|
|
if(e.title&&e.title.trim()) return {text:e.title, derived:false};
|
|
const words=plainText(e.description);
|
|
if(words){
|
|
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
|
|
return {text:cut, derived:true};
|
|
}
|
|
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop()!.replace(/\.[a-z0-9]{1,5}$/i,'');
|
|
if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } }
|
|
return {text:[feedName(e.feed_id), dateOf(e.published??null)].filter(Boolean).join(', '), derived:true};
|
|
}
|
|
|
|
/// Selecting an item shows it in the pane below, rather than expanding the row.
|
|
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
|
|
function swapRow(e: Entry){
|
|
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
|
|
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
|
|
}
|
|
|
|
/// Read and unread as this page last set them, and when the server had it. A list asked for
|
|
/// before then answers with the old state: it put the dot back on an item just read, until
|
|
/// the next refresh took it off again (loadEntries).
|
|
const readWrites=new Map<string, {read: boolean, done?: number}>();
|
|
const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
|
|
function setRead(e: Entry,read: boolean){
|
|
e.read=read;
|
|
// Kept lists of its feed hold their own copy of it, still as it was.
|
|
forgetLists(e.feed_id);
|
|
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
|
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
|
{method:'POST',body:JSON.stringify({read})})
|
|
.then(row=>{ w.done=performance.now(); if(row) patchFeed(row); if(S.signals.length) refreshSignals(); });
|
|
}
|
|
|
|
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
|
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
|
|
function markRead(e: Entry){
|
|
if(e.read) return;
|
|
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
|
}
|
|
|
|
/// On the Unread tab, the items read while turning from one to the next (a swipe, j and k), kept
|
|
/// in the list until the reader closes or another item is picked from the list. Dropped as each
|
|
/// was left, a swipe back had nothing to go back to: the item just read was already gone.
|
|
const turned=new Set<string>();
|
|
function dropTurned(keep: string|null){
|
|
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
|
|
turned.clear();
|
|
if(!gone.length) return;
|
|
S.entries=S.entries.filter(x=>!gone.includes(x)); S.total-=gone.length;
|
|
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
|
|
}
|
|
|
|
function selectEntry(e: Entry,turning=false){
|
|
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh
|
|
// next happens to come along, which left a few read ones in the list for a while.
|
|
if(S.filter==='unread' && S.sel && S.sel!==e.guid){
|
|
turned.add(S.sel);
|
|
if(!turning) dropTurned(e.guid);
|
|
}
|
|
S.sel=e.guid;
|
|
markRead(e);
|
|
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid));
|
|
swapRow(e);
|
|
showDetail(e);
|
|
const d=$('#detail'); if(d) d.scrollTop=0;
|
|
}
|
|
|
|
/// Drag the divider between the item list and the item text.
|
|
function dragSplit(pane: HTMLElement){
|
|
const grab=$('#grab',pane);
|
|
if(!grab) return;
|
|
const move=(ev: MouseEvent)=>{
|
|
const box=pane.getBoundingClientRect();
|
|
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
|
|
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
|
|
};
|
|
const stop=()=>{
|
|
document.removeEventListener('mousemove',move);
|
|
document.removeEventListener('mouseup',stop);
|
|
document.body.style.userSelect='';
|
|
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
|
|
};
|
|
grab.onmousedown=(ev: MouseEvent)=>{
|
|
ev.preventDefault();
|
|
document.body.style.userSelect='none';
|
|
document.addEventListener('mousemove',move);
|
|
document.addEventListener('mouseup',stop);
|
|
};
|
|
}
|
|
|
|
/// Which item the toolbar's play, read and keep buttons act on.
|
|
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
|
|
// the first. Key rows by feed as well if that ever happens.
|
|
const cur=()=>S.entries.find(x=>x.guid===S.sel);
|
|
function syncTools(e: Entry|null){
|
|
$<HTMLButtonElement>('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
|
$<HTMLButtonElement>('#tbRead').disabled=$<HTMLButtonElement>('#tbFlag').disabled=!e;
|
|
// The same icons as the item's own buttons beside its title, so the two never disagree. Each
|
|
// shows what is, as the pin always did: read showed what a click would do, so the two side by
|
|
// side said opposite things (#74). The shape carries it, not the colour.
|
|
$<HTMLButtonElement>('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
|
$<HTMLButtonElement>('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
|
$<HTMLButtonElement>('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
|
$<HTMLButtonElement>('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
|
if(e){
|
|
$<HTMLButtonElement>('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
|
$<HTMLButtonElement>('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
|
|
}
|
|
}
|
|
|
|
/// The item's text in the pane below the list, and its files in the pane beside it.
|
|
function showDetail(e: Entry|null){
|
|
const box=$('#detail'), files=$('#files'); if(!box) return;
|
|
// A pane that can only say "No files" gives its width to the list instead.
|
|
if(files) files.hidden=!(e&&e.enclosures.length);
|
|
document.body.classList.toggle('reading',!!e);
|
|
syncTools(e);
|
|
if(!e){
|
|
dropTurned(S.sel);
|
|
box.innerHTML='';
|
|
if(files) files.innerHTML='<p class="empty">No files</p>';
|
|
return;
|
|
}
|
|
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
|
|
// where the files sit over the text, a box saying "No files" only pushed the text down.
|
|
const encs=e.enclosures.map(encBox).join('');
|
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
|
box.innerHTML=detailHtml(e);
|
|
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
|
$<HTMLButtonElement>('#dback').onclick=()=>showDetail(null);
|
|
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
|
b.onclick=()=>epAction(b.dataset.a!,e,null,b.dataset.enc?Number(b.dataset.enc):undefined));
|
|
syncPlayButtons();
|
|
}
|
|
|
|
/// What the reader shows for an item. Its own function because a phone also draws the next or
|
|
/// previous item beside the reader while it is swiped (gestures.ts).
|
|
function detailHtml(e: Entry){
|
|
const encs=e.enclosures.map(encBox).join('');
|
|
// A phone has no room for the files pane, so the files go above the text there instead.
|
|
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
|
|
// it looked as if a downloaded file could not be deleted at all (issue #21).
|
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
|
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
|
// description was sanitized server-side with ammonia before it ever reached here
|
|
return `
|
|
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
|
|
${/* A post without a title starts with its text: its own first words as a heading above
|
|
themselves would read as a mistake. */ e.title&&e.title.trim() ? `<h3 class="dt">${esc(e.title)}</h3>` : ''}
|
|
<div class="dmeta">
|
|
${/* Joined, so a missing date or number leaves no stray dot behind. */
|
|
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration),
|
|
e.enclosures.some(x=>x.deadair_pending)&&DEAD_PENDING]
|
|
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
|
|
<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
|
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
|
|
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
|
aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
|
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
|
|
title="Open the original" aria-label="Open the original">${ICON.open}</a>
|
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>
|
|
</div>
|
|
${narrow?encs:''}
|
|
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
|
}
|
|
|
|
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
|
function encBox(x: Enclosure){
|
|
const size=x.length?mb(x.length):'';
|
|
// One file serves everyone reading the feed, so deleting is not a private act.
|
|
const f=S.feeds.find(y=>y.id===x.feed_id);
|
|
const shared=f&&f.subscribers>1;
|
|
// Icons, with the words in the tooltip and for screen readers.
|
|
const delLabel=shared
|
|
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
|
|
: 'Delete file';
|
|
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
|
// Played without its dead air, so saved without it too (#186).
|
|
const dead=(x.deadair??0)>0;
|
|
const saveLabel=dead?'Save to this computer, without its dead air':'Save to this computer';
|
|
const saveBtn=`<a class="btn ico" href="/media/${x.id}${dead?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
|
|
// The publisher's address, not this server's copy, which only someone signed in here can open.
|
|
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
|
|
if(x.path && !isPlayable(x)){
|
|
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
|
return `<div class="encbox">
|
|
${kindIcon(x)}
|
|
<span class="meta" style="flex:1">${size}</span>
|
|
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
|
|
${saveBtn}
|
|
${shareBtn}
|
|
${delBtn}
|
|
</div>`;
|
|
}
|
|
if(x.path){
|
|
// One player, the bar at the bottom. This pane had an <audio> of its own, and playing it
|
|
// started the bar as well, so the same file played twice at once.
|
|
return `<div class="encbox">
|
|
${kindIcon(x)}
|
|
<span class="meta" style="flex:1">${size}</span>
|
|
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
|
|
${saveBtn}
|
|
${shareBtn}
|
|
${delBtn}
|
|
</div>`;
|
|
}
|
|
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
|
|
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
|
|
// through here, which would make ipx a fetch-anything relay.
|
|
const viewable = !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
|
|
return `<div class="encbox">
|
|
${kindIcon(x)}
|
|
<span class="meta" style="flex:1">${size}</span>
|
|
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
|
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
|
|
${shareBtn}
|
|
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
|
|
</div>`;
|
|
}
|
|
|
|
async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){
|
|
// Swap the row in place and refresh the text below when it is the one being read.
|
|
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
|
|
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
|
|
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
|
try{
|
|
if(a==='play') play(e, encId!=null ? enc : undefined);
|
|
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link!, el??undefined);
|
|
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
|
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
|
if(a==='get'){
|
|
if(!enc) return;
|
|
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
|
|
toast('Queued: '+entryName(e).text);
|
|
}
|
|
if(a==='del'){
|
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
|
const shared=f&&f.subscribers>1;
|
|
if(!confirm(shared
|
|
? `Delete this file?\n\nThere is one copy, shared with ${f.subscribers-1} other `
|
|
+`${f.subscribers===2?'person':'people'} reading this feed. The item stays listed `
|
|
+`and will not be downloaded again automatically.`
|
|
: 'Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return;
|
|
try{
|
|
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
|
|
}catch(err){
|
|
// 409: somebody else has it starred or unplayed. Their reason, their words.
|
|
if(!/one copy of this file/.test(why(err))) throw err;
|
|
if(!confirm(why(err)+'\n\nDelete it anyway?')) return;
|
|
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
|
|
}
|
|
toast('Deleted'); loadEntries(); loadFeeds(true);
|
|
}
|
|
}catch(err){ toast(why(err),true); }
|
|
}
|
|
|