Files
ipx/web/src/items.ts
rays 5ef01593d7 One place to get an episode's file where you are (#457)
Getting an episode onto a phone took two buttons in two states: download
it to the server, wait, then keep it on the device, beside Save to this
computer, Share and Delete, five buttons that did not say where each
copy went. Now a file has one button that brings it to where you are.
On a phone or in the iOS app it ends on the device; when iPX has not got
the file yet, iPX fetches it and the device's copy follows once iPX says
it is done (remembered in localStorage, so a page closed meanwhile
catches up when a list next shows the file). On a computer it has iPX
fetch the file, and with iPX's copy there saves one. A line beside the
size says where the file is, and the rarer actions, two of them
destructive, are under a menu.

isPlayable is false for any file not on iPX, so the phone's button is
decided by playsKind, the same test without the path. download_done now
marks the file as on iPX in what the page holds, so the open file's box
changes at once rather than after the list is fetched again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n
2026-11-13 18:40:49 +00:00

662 lines
37 KiB
TypeScript

/* ---------------- items ---------------- */
/// An item is its feed and its guid: two feeds can give one post the same guid, as a blog's RSS
/// and Atom feeds do, and keyed by guid alone, All Subscriptions marked the first of the two
/// whichever was clicked (#361).
const entryKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
/// Whether this item is the one in the player.
const inPlayer=(e: Entry)=>player.guid===e.guid&&player.feed===e.feed_id;
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(r) savePictures(r.entries);
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<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, 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,append?S.offset:0)));
}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;
// Reading an item takes it off the server's Unread list and off nextOffset() alike, so what is
// left there stays as this answer says; S.total against nextOffset() said more once read (#385).
S.more=(append?S.offset:0)+r.entries.length<r.total;
for(const e of r.entries){
const w=readWrites.get(entryKey(e));
if(w && !(w.done!==undefined&&w.done<asked)) e.read=w.read;
}
// An item new since the last page shifts the next one down by it, repeating one already here.
if(append) savePictures(r.entries);
let entries = append ? S.entries.concat(r.entries.filter(e=>!S.entries.some(x=>entryKey(x)===entryKey(e)))) : 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)=>entryKey(e)===S.sel||turned.has(entryKey(e));
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=>entryKey(n)===entryKey(e)));
entries=[...gone,...entries];
}
// Reloaded for a new version (#388): the item that was open, open again, where it was in the list.
const back=append ? null : takeReopen();
if(back && !entries.some(e=>entryKey(e)===entryKey(back))) entries=[back,...entries];
S.entries = entries;
followWantedIn(entries);
renderEntries();
if(back){ const e=S.entries.find(x=>entryKey(x)===entryKey(back)); if(e) selectEntry(e); }
}
/// 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);
// 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='<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. New items are read as your feeds are checked.</p>';
return;
}
for(const e of S.entries) box.appendChild(epEl(e));
syncPlayButtons();
if(pane) pane.scrollTop=top;
if(hasMore()){
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=nextOffset();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===entryKey(e)?' sel':'');
el.dataset.guid=e.guid; el.dataset.key=entryKey(e);
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=`
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}" aria-label="Mark ${e.read?'unread':'read'}">${
inPlayer(e)?EQ:(e.read?'':'<span aria-hidden="true">●</span>')}</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">
<button type="button" class="t${name.derived?' notitle':''}">${esc(name.text)}</button>
<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,
// Kept on this device to play offline (#413), which only the files pane said.
e.enclosures.some(isKept)&&`<span class="kept">${ICON.kept}On this device</span>`,
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){
return !!x && !!x.path && playsKind(x);
}
/// Whether a file is audio or video, wherever it is: one not downloaded yet too.
function playsKind(x: {mime?: string|null, path?: string|null, url?: string}){
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-key="${CSS.escape(entryKey(e))}"]`);
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}>();
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(entryKey(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(entryKey(e)); toast(why(err),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(entryKey(x))&&x.read&&entryKey(x)!==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-key="${CSS.escape(entryKey(x))}"]`)?.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!==entryKey(e)){
turned.add(S.sel);
if(!turning) dropTurned(entryKey(e));
}
S.sel=entryKey(e);
markRead(e);
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.key===entryKey(e)));
swapRow(e);
showDetail(e);
const d=$('#detail'); if(d) d.scrollTop=0;
if(turning) loadAhead();
}
/// Where the next page starts. On the Unread tab the items read here are no longer unread on the
/// server, so counting them, as a flat 50 a page did, skipped as many unread ones.
function nextOffset(){ return S.filter==='unread' ? S.entries.filter(e=>!e.read).length : S.entries.length; }
const hasMore=()=>S.more;
/// Turning near the end of what is loaded fetches the next page behind: only the list's Load more
/// button did, and a swipe or j stopped dead at the 50th item until a reload (#383).
let loadingAhead=false;
function loadAhead(){
const i=S.entries.findIndex(x=>entryKey(x)===S.sel);
if(loadingAhead || S.entries.length-1-i>5 || !hasMore()) return;
loadingAhead=true; S.offset=nextOffset();
loadEntries(true).catch(()=>{}).finally(()=>{ loadingAhead=false; });
}
/// 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.
const cur=()=>S.entries.find(x=>entryKey(x)===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);
const view=box.querySelector<HTMLSelectElement>('.tview');
if(view) view.onchange=()=>{
textView=view.value;
try{ localStorage.setItem('ipx.textview',textView); }catch{}
box.querySelector('.dbody')!.innerHTML=textOf(e);
};
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('');
const back=`<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>`;
const acts=`<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>
${webUrl(e.link)?`<a class="btn ico" href="${esc(webUrl(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>`;
// description was sanitized server-side with ammonia before it ever reached here
return `
${/* On a phone, back and the item's actions float over the text as it scrolls, so they are
at hand anywhere in a long post, not only back at its top. */
narrow ? `<div class="dfloat">${back}${acts}</div>` : back}
${/* 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>')}
${e.markdown?`<select class="tview" aria-label="Show the text as">${TEXT_VIEWS.map(([v,label])=>
`<option value="${v}"${v===textView?' selected':''}>${label}</option>`).join('')}</select>`:''}
${narrow?'':acts}
</div>
${narrow?encs:''}
<div class="dbody">${textOf(e)}</div>`;
}
/// How an item whose feed sends Markdown beside its HTML is shown, kept from one item to the
/// next (#285): the publisher's HTML, or the Markdown made HTML.
const TEXT_VIEWS=[['html','HTML'],['markdown','Markdown']];
let textView=(()=>{ try{ return localStorage.getItem('ipx.textview'); }catch{ return null; } })()||'html';
function textOf(e: Entry){
// Cleaned server-side with ammonia, as the description is.
if(e.markdown&&textView==='markdown'&&e.markdown_html) return e.markdown_html;
return (e.description&&e.description.trim())||'<em>No show notes.</em>';
}
/// Where a file is, in the words its box says it in (#457): the publisher's alone, on its way to
/// iPX, on iPX, on this device as well.
function whereText(x: Enclosure){
const here=isKept(x), busy=keeping.has(keepUrl(x));
if(!x.path){
if(here) return 'On this device';
if(wanted.has(x.id)) return 'Downloading to iPX, then this device';
if(x.state==='pending') return 'Queued for iPX';
if(x.state==='error') return 'iPX could not download it';
return 'Publisher only';
}
return here ? 'On iPX and this device' : busy ? 'On iPX, downloading to this device' : 'On iPX';
}
/// Whether ⬇ ends on this device: a phone or the app, where the file is wanted in the hand, for a
/// file this device can play offline. A computer's ⬇ has iPX fetch it and saves a copy.
const toDevice=(x: Enclosure)=>canKeep() && playsKind(x)
&& (!!ipxHost || !!window.matchMedia?.('(max-width:820px)')?.matches);
/// One enclosure (#457): what it is, where it is, and one button that brings it to where you are.
/// Everything else, rarer and two of it destructive, is under ⋯.
function encBox(x: Enclosure){
const size=x.length?mb(x.length):'';
const btn=(a: string, label: string, icon: string, extra='')=>
`<button class="btn ico" data-a="${a}" data-enc="${x.id}" title="${label}" aria-label="${label}"${extra}>${icon}</button>`;
// Played without its dead air, so saved without it too (#186).
const dead=(x.deadair??0)>0;
let get='';
if(toDevice(x)){
const url=keepUrl(x);
get=isKept(x) ? btn('more', 'On this device. More', ICON.kept, ' aria-pressed="true"')
: keeping.has(url) ? btn('offline', 'Downloading to this device', ICON.download, ` data-url="${url}" aria-busy="true"`)
: wanted.has(x.id) ? btn('want', 'Downloading to iPX, then this device', ICON.download, ' aria-busy="true"')
: x.path ? btn('offline', 'Download to this device', ICON.download, ` data-url="${url}"`)
: btn('want', 'Download to this device', ICON.download);
}else if(x.path){
const label=dead?'Save a copy to this computer, without its dead air':'Save a copy to this computer';
get=`<a class="btn ico" href="/media/${x.id}${dead?'/deadair':''}" download title="${label}" aria-label="${label}">${ICON.download}</a>`;
}else if(x.state!=='pending') get=btn('get', 'Download to iPX', ICON.download);
// Nothing on disk, an image or a PDF: you usually just want to look at it, so the publisher's
// copy in a new tab, nothing proxied through here, which would make ipx a fetch-anything relay.
const view=x.path && !isPlayable(x) ? `/media/${x.id}`
: !x.path && !!webUrl(x.url) && !isPlayable(x) && x.state!=='pending' && x.state!=='held' ? x.url : '';
// 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, whereText(x)].filter(Boolean).join(' · ')}</span>
${x.state==='error'&&x.last_error&&!x.path?`<span class="err">${esc(x.last_error)}</span>`:''}
${x.path&&isPlayable(x)?btn('play', 'Play', ICON.play):''}
${view?`<a class="btn ico" href="${esc(view)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
${get}
${/* The publisher's address, not this server's copy, which only someone signed in here can open. */''}
${btn('share', 'Share the file', ICON.share)}
${fileMenuItems(x).length?btn('more', 'More', ICON.more):''}
</div>`;
}
/// What ⋯ offers for a file: whatever its one button does not.
function fileMenuItems(x: Enclosure): [string, string, string][]{
const items: [string, string, string][]=[];
const device=toDevice(x);
if(isKept(x)) items.push(['offline', 'Remove from this device', '']);
else if(!device && canKeep() && isPlayable(x) && x.path && !keeping.has(keepUrl(x)))
items.push(['offline', 'Download to this device, to play offline', '']);
if(device && !x.path && x.state!=='pending' && !wanted.has(x.id)) items.push(['get', 'Download to iPX only', '']);
if(x.path){
// 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);
items.push(['del', f&&f.subscribers>1
? `Delete from iPX, for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
: 'Delete from iPX', ' danger']);
}
return items;
}
function fileMenu(e: Entry, x: Enclosure){
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
<h3>${esc(entryName(e).text)}</h3><p class="hint">${esc(whereText(x))}</p>
<div class="sharemenu">${fileMenuItems(x).map(([a,label,cls])=>
`<button class="btn${cls}" data-m="${a}">${esc(label)}</button>`).join('')}</div>`);
$$<HTMLButtonElement>('#modalCard [data-m]').forEach(b=>b.onclick=()=>{ closeModal(); epAction(b.dataset.m!, e, null, x.id); });
}
/// A shared link that starts at a time (#447): ipx-play's page reads `?t=` in seconds. Under five
/// seconds in is the start.
const shareAt=(url: string, secs: number)=>secs>=5 ? `${url}?t=${Math.floor(secs)}` : url;
/// Why Share offers the item's own address rather than a page anyone can play (#454), and the
/// warning when that address carries your own access to the feed. No reason for a server with no
/// ipx-play, where the item's own address is all Share has ever given.
function noLinkNote(why: string|null, url: string): string{
const notes: string[]=[];
if(why==='private') notes.push('This podcast\'s feed is private to you, so it gets no page anyone can play, and its file is never shared: this is the episode\'s own page.');
else if(why==='failed') notes.push('The page anyone can play could not be made just now, so this is the episode\'s own address. Try again later for a link to it.');
if(carriesAccess(url)) notes.push(ACCESS_WARNING);
return notes.map(n=>`<p class="hint">${esc(n)}</p>`).join('');
}
/// An episode shared as Overcast shares one (#447): a link to its page on ipx-play, where anyone
/// can play it, from where you are in it if you like. A post, or a server with no ipx-play, shares
/// the item's own address through the same menu; a private feed's episode, its page alone.
async function shareEpisode(e: Entry, encId: number|null){
const name=entryName(e).text;
const own=encId!=null ? e.enclosures.find(x=>x.id===encId)?.url : e.link;
let link: {url: string|null, why: string|null}={url:null, why:null};
try{
link=await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/share`,
{method:'POST', body:JSON.stringify(encId!=null ? {enc:encId} : {})});
}catch{}
// A private feed's file can be signed for you (Patreon's are), so only the episode's page goes,
// even from Share on the file (#455).
const url=link.url ?? (link.why==='private' ? e.link : own);
if(!url){
toast(link.why==='private' ? 'This podcast is private to you, and this episode has no page of its own to share.'
: 'There is nothing to share for this item.', true);
return;
}
// Where you are: the player's clock when it has this one, else where you left off. Only
// ipx-play's page reads a time.
const at=!link.url ? 0 : player.guid===e.guid && player.feed===e.feed_id ? audio.currentTime : e.position;
// A menu rather than the share sheet at once, for the item's own address too: the sheet needs a
// press of its own, and the request above has used up the one that opened this (#452). The
// warning is written here, not asked with confirm(), which the iOS app answers Cancel (#453).
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
<h3>Share</h3><p class="hint">${esc(name)}</p>
${link.url ? '' : noLinkNote(link.why, url)}
<div class="sharemenu">
<button class="btn primary" id="shlink">${ICON.share} Share link</button>
${at>=5?`<button class="btn" id="shat">${ICON.share} Share from ${clock(at)}</button>`:''}
<button class="btn" id="shcopy">${ICON.copy} Copy link</button>
</div>
${link.url ? '<p class="hint">Anyone with the link can play it, and subscribe in their own podcast app.</p>' : ''}`);
$<HTMLButtonElement>('#shlink').onclick=()=>{ closeModal(); sendLink(name, url); };
const timed=$<HTMLButtonElement>('#shat');
if(timed) timed.onclick=()=>{ closeModal(); sendLink(name, shareAt(url, at)); };
$<HTMLButtonElement>('#shcopy').onclick=async()=>{ await copyText(url, $<HTMLButtonElement>('#shcopy')); toast('Link copied'); closeModal(); };
}
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.
// The reader keeps its place: on a phone read and pin are pressed anywhere in a long post.
const redraw=()=>{
swapRow(e);
if(el && S.sel!==entryKey(e)) return;
const box=$('#detail'), top=box?.scrollTop??0;
showDetail(e);
if(box) box.scrollTop=top;
};
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'){ haptic('tap'); play(e, encId!=null ? enc : undefined); }
if(a==='offline' && enc) await toggleKept(enc, entryName(e).text);
if(a==='share') await shareEpisode(e, encId??null);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); haptic('tap'); redraw(); }
if(a==='read'){ await setRead(e,!e.read); haptic(e.read?'done':'tap'); redraw(); }
if(a==='more' && enc) fileMenu(e, enc);
if(a==='get' || a==='want'){
if(!enc) return;
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
enc.state='pending';
// Wanted here too: the device's copy follows once iPX has the file (#457).
if(a==='want'){ want(enc.id, entryName(e).text); toast('Downloading: '+entryName(e).text+'. It comes to this device once iPX has it.'); }
else toast('Queued: '+entryName(e).text);
redraw();
}
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); }
}