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
This commit is contained in:
@@ -38,6 +38,11 @@ function connect(){
|
||||
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
||||
// everyone's feeds used to announce themselves to everyone.
|
||||
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
|
||||
// On iPX now, in what this page holds too, so the open file's box says so at once rather
|
||||
// than after the list comes again; and, asked for on this device, its copy here follows (#457).
|
||||
for(const e of S.entries) for(const x of e.enclosures) if(x.id===ev.enclosure){ x.path=ev.path; x.state='done'; }
|
||||
if(wanted.has(ev.enclosure)) followWanted({id: ev.enclosure, path: ev.path});
|
||||
else syncKeepButtons();
|
||||
refreshEntries();
|
||||
}
|
||||
// A copy finished: the episode's "Cutting dead air…" becomes how much shorter it is.
|
||||
@@ -50,6 +55,7 @@ function connect(){
|
||||
else if(ev.ev==='download_error'){
|
||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||
if(bar) bar.classList.remove('live');
|
||||
unwant(ev.enclosure);
|
||||
// Which item, and what to do: the reason (ev.msg) is in the daemon's log (#304).
|
||||
const e=S.entries.find(x=>x.enclosures.some(n=>n.id===ev.enclosure));
|
||||
toast(`Unable to download ${e?`"${entryName(e).text}"`:'a file'}. Try again with its download button.`,true); refreshEntries();
|
||||
|
||||
140
web/src/items.ts
140
web/src/items.ts
@@ -129,6 +129,7 @@ function showList(r: ListPage, append: boolean, asked: number){
|
||||
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); }
|
||||
}
|
||||
@@ -219,7 +220,10 @@ function epEl(e: Entry){
|
||||
/// 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;
|
||||
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;
|
||||
@@ -467,59 +471,88 @@ function textOf(e: Entry){
|
||||
return (e.description&&e.description.trim())||'<em>No show notes.</em>';
|
||||
}
|
||||
|
||||
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
||||
/// 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):'';
|
||||
// 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 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;
|
||||
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>
|
||||
${keepBtn(x)}
|
||||
${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 = !!webUrl(x.url) && !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
|
||||
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}</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>`;
|
||||
${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
|
||||
@@ -595,10 +628,15 @@ async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: numbe
|
||||
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==='get'){
|
||||
if(a==='more' && enc) fileMenu(e, enc);
|
||||
if(a==='get' || a==='want'){
|
||||
if(!enc) return;
|
||||
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
|
||||
toast('Queued: '+entryName(e).text);
|
||||
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);
|
||||
|
||||
@@ -46,24 +46,41 @@ if(worker) (async()=>{
|
||||
try{ for(const k of await (await caches.open(KEPT)).keys()) kept.add(new URL(k.url).pathname); }catch{}
|
||||
if(kept.size) syncKeepButtons();
|
||||
})();
|
||||
/// The open item's files say where each is, and its button what it does next (#457): drawn again
|
||||
/// in place, keeping the reader where it was.
|
||||
function syncKeepButtons(){
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-a="offline"]').forEach(b=>{
|
||||
const k=kept.has(b.dataset.url!);
|
||||
b.setAttribute('aria-pressed',String(k));
|
||||
b.setAttribute('aria-busy',String(keeping.has(b.dataset.url!)));
|
||||
b.innerHTML=k?ICON.kept:ICON.keep;
|
||||
const label=k?'Kept on this device to play offline. Remove it':'Keep on this device to play offline';
|
||||
b.title=label; b.setAttribute('aria-label',label);
|
||||
}); // A row says whether its item is kept; redrawn only where that has changed.
|
||||
const e=cur();
|
||||
if(e && $('#files .encbox, #detail .encbox')){
|
||||
const box=$('#detail'), top=box?.scrollTop??0;
|
||||
showDetail(e);
|
||||
if(box) box.scrollTop=top;
|
||||
}
|
||||
// A row says whether its item is kept; redrawn only where that has changed.
|
||||
for(const e of S.entries)
|
||||
if(e.enclosures.some(isKept)!==!!$(`#eps .ep[data-key="${CSS.escape(entryKey(e))}"] .kept`)) swapRow(e);
|
||||
}
|
||||
/// The button for a file on the server that plays; none without a worker, or an app, to play it
|
||||
/// offline.
|
||||
function keepBtn(x: Enclosure){
|
||||
if(!worker&&!ipxHost) return '';
|
||||
const k=isKept(x), label=k?'Kept on this device to play offline. Remove it':'Keep on this device to play offline';
|
||||
return `<button class="btn ico" data-a="offline" data-enc="${x.id}" data-url="${keepUrl(x)}" aria-pressed="${k}" aria-busy="${keeping.has(keepUrl(x))}" title="${label}" aria-label="${label}">${k?ICON.kept:ICON.keep}</button>`;
|
||||
/// Whether this device can keep a file to play offline: a worker to keep it, or the app.
|
||||
const canKeep=()=>!!worker||!!ipxHost;
|
||||
/* Files asked for on this device before iPX had them (#457): iPX fetches each, and its copy here
|
||||
follows once iPX says it is done, or, when the page was closed meanwhile, the next time a list
|
||||
shows it with its file. Kept in localStorage, so a closed app still remembers. */
|
||||
const wanted=new Map<number,string>();
|
||||
try{ for(const [id,name] of JSON.parse(localStorage.getItem('ipx.wanted')||'[]') as [number,string][]) wanted.set(id,name); }catch{}
|
||||
function saveWanted(){ try{ localStorage.setItem('ipx.wanted',JSON.stringify([...wanted])); }catch{} }
|
||||
function want(id: number, name: string){ wanted.set(id,name); saveWanted(); }
|
||||
function unwant(id: number){ if(wanted.delete(id)) saveWanted(); }
|
||||
/// iPX has the file now: this device's copy follows. The copy without dead air comes later, if it
|
||||
/// does, so the file itself is what is kept.
|
||||
function followWanted(x: {id: number, path?: string|null, deadair?: number|null}){
|
||||
const name=wanted.get(x.id);
|
||||
if(name==null || !x.path) return;
|
||||
unwant(x.id);
|
||||
if(!isKept(x)) void toggleKept(x as Enclosure, name);
|
||||
}
|
||||
/// Those of `entries` iPX fetched while this page was not looking.
|
||||
function followWantedIn(entries: Entry[]){
|
||||
if(!wanted.size) return;
|
||||
for(const e of entries) for(const x of e.enclosures) if(x.path && wanted.has(x.id)) followWanted(x);
|
||||
}
|
||||
async function toggleKept(x: Enclosure, name: string){
|
||||
const url=keepUrl(x);
|
||||
|
||||
@@ -28,6 +28,7 @@ const fa=(box: string,body: string)=>`<svg class="i" viewBox="${box}" aria-hidde
|
||||
/// An app-icon tile (web/signals.svg, deadair-on.svg, ...) in the mode's own variant, as the logo is.
|
||||
const tile=(name: string)=>`<img class="tile onlight" src="/${name}.svg" alt=""><img class="tile ondark" src="/${name}-dark.svg" alt="">`;
|
||||
const ICON: Record<string,string>={
|
||||
more:fa('0 0 448 512','<path fill="currentColor" d="M8 256a56 56 0 1 1 112 0A56 56 0 1 1 8 256zm160 0a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm216-56a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/>'), // solid/ellipsis, for a file's other actions (#457)
|
||||
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
|
||||
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
||||
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
||||
|
||||
Reference in New Issue
Block a user