A share button in the feed's header, beside an item's "Open the original", and on each file. It uses the Web Share API where the browser has it, which is the share sheet on a phone, and copies the link where it does not. A file is shared by the publisher's address, not /media/, which only someone signed in here can open. Paid feeds carry the subscriber's access in their address, Patreon's in a token, so sharing one gives the subscription away. An address that looks like that asks first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
18 KiB
TypeScript
352 lines
18 KiB
TypeScript
/* ---------------- items ---------------- */
|
|
let loadSeq=0;
|
|
async function loadEntries(append?: boolean){
|
|
// Directory and Popular list feeds, not items.
|
|
if(!S.feed || VIEWS[S.feed]?.url) return;
|
|
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
|
|
if(S.q) p.set('q',S.q);
|
|
const asked=performance.now(), seq=++loadSeq;
|
|
const r=await api(S.feed===':all' ? `/api/entries?${p}`
|
|
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
|
// 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;
|
|
S.total=r.total;
|
|
for(const e of r.entries){
|
|
const w=readWrites.get(readKey(e));
|
|
if(w && !(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.
|
|
if(S.filter==='unread') entries=entries.filter(e=>!e.read||e.guid===S.sel);
|
|
if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){
|
|
const open=S.entries.find(e=>e.guid===S.sel);
|
|
if(open) entries=[open,...entries];
|
|
}
|
|
S.entries = entries;
|
|
renderEntries();
|
|
}
|
|
function renderEntries(){
|
|
const box=$('#eps'); if(!box) return;
|
|
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed];
|
|
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
|
$('#count').textContent=
|
|
`${f?(f.title||f.id):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){
|
|
box.innerHTML=`<p class="empty">${S.q?'Nothing matches that search.':'Nothing here yet — try Scan now.'}</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){
|
|
// 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):'');
|
|
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" title="${
|
|
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
|
<div class="body">
|
|
<span class="t">${esc(e.title||'(untitled)')}</span>
|
|
<div class="line">${[
|
|
num&&`<span>${num}</span>`,
|
|
left&&`<span>${left}</span>`,
|
|
others>0&&`<span>+${others} more file${others===1?'':'s'}</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=>{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){
|
|
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){
|
|
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){
|
|
const k=kindOf(enc);
|
|
const i={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=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
|
|
|
|
/// 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){
|
|
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();
|
|
const readKey=e=>e.feed_id+'\n'+e.guid;
|
|
function setRead(e,read){
|
|
e.read=read;
|
|
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(()=>{ w.done=performance.now(); loadFeeds(true); });
|
|
}
|
|
|
|
/// 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){
|
|
if(e.read) return;
|
|
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
|
}
|
|
|
|
function selectEntry(e){
|
|
// 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.
|
|
const prev=S.filter==='unread' && S.sel!==e.guid && S.entries.find(x=>x.guid===S.sel);
|
|
if(prev&&prev.read){
|
|
S.entries=S.entries.filter(x=>x!==prev); S.total--;
|
|
$(`#eps .ep[data-guid="${CSS.escape(prev.guid)}"]`)?.remove();
|
|
}
|
|
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){
|
|
const grab=$('#grab',pane);
|
|
if(!grab) return;
|
|
const move=ev=>{
|
|
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=>{
|
|
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){
|
|
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
|
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
|
// The same icons as the item's own buttons beside its title, so the two never disagree.
|
|
$('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check;
|
|
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
|
if(e){
|
|
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
|
$('#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){
|
|
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){
|
|
box.innerHTML='<p class="empty">Pick an item to read it.</p>';
|
|
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('');
|
|
// 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('');
|
|
box.innerHTML=`
|
|
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
|
|
<h3 class="dt">${esc(e.title||'(untitled)')}</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)]
|
|
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
|
|
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
|
aria-label="Mark ${e.read?'unread':'read'}">${e.read?ICON.unread:ICON.check}</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'}">${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>`:''}
|
|
</div>
|
|
${narrow?encs:''}
|
|
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
|
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
|
// description was sanitized server-side with ammonia before it ever reached here
|
|
$('#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):null));
|
|
syncPlayButtons();
|
|
}
|
|
|
|
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
|
function encBox(x){
|
|
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>`;
|
|
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${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';
|
|
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, el, 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(e.title||'', encId!=null ? enc.url : e.link, el);
|
|
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: '+(e.title||'item'));
|
|
}
|
|
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(err.message)) throw err;
|
|
if(!confirm(err.message+'\n\nDelete it anyway?')) return;
|
|
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
|
|
}
|
|
toast('Deleted'); loadEntries(); loadFeeds(true);
|
|
}
|
|
}catch(err){ toast(err.message,true); }
|
|
}
|
|
|