/* ---------------- modals ---------------- */ function openModal(html: string, wide?: boolean){ $('#modalCard').innerHTML=html; $('#modalCard').classList.toggle('wide',!!wide); $('#modal').classList.add('on'); } function closeModal(){ $('#modal').classList.remove('on'); } $('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); }; $('#addFeed').onclick=()=>{ openModal(`

Add a feed

A Patreon token on its own adds every show from that creator.
Only items matching a keyword are downloaded.
`); $('#nurl').focus(); $('#nsave').onclick=async()=>{ const url=$('#nurl').value.trim(); if(!url) return; $('#nsave').disabled=true; $('#nsave').title='Adding…'; try{ const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({ url, folder:$('#nfolder').value.trim()||null, allow_explicit:$('#nexp').checked, keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})}); closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`); await loadFeeds(true); selectFeed(r.id); }catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; } }; }; // What everyone here reads, you included, as a place to start. The rows carry an id, never a // URL, so a key in someone's feed address never reaches this page. const NONE_LISTED='

Nothing yet. Feeds people here subscribe to show up here.

'; async function listFeeds(url,box){ let rows=[]; try{ rows=await api(url)||[]; }catch{} box.innerHTML=rows.length?'':NONE_LISTED; for(const p of rows) box.appendChild(listedFeed(p,'childrow')); return rows.length; } /// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The /// parts are the same either way; the class lays them out. function listedFeed(p,cls){ const el=document.createElement('div'); el.className=cls; el.innerHTML=artHTML(p.image,p.title||p.id)+ `
${esc(p.title||p.id)}`+ `${p.subscribers} subscriber${p.subscribers===1?'':'s'}
`+ // Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one. (p.subscribed?`${ICON.subbed}` :``); // Yours already: the row opens it instead. if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; } $('[data-a="sub"]',el).onclick=async()=>{ try{ await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'}); closeModal(); toast(`Subscribed to ${p.title||p.id}`); await loadFeeds(true); selectFeed(p.id); }catch(e){ toast(e.message,true); } }; return el; } // Directory's filters. Kept out here because a finished scan redraws the pane, which would // otherwise clear them. let dirKind='All', dirCat=null; const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast}; /// Directory: every listed feed as its cover art, under two filters that combine: what a feed is /// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes /// category, as chips). Both filter in place, without asking the server again. async function renderDirectory(url,box){ let rows=[]; try{ rows=await api(url)||[]; }catch{} if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; } const bar=$('#dirbar'); // Only a filter when the server has both kinds. const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs); const btn=(k,v,on)=>``; const draw=()=>{ if(!both) dirKind='All'; const ofKind=rows.filter(KINDS[dirKind]); // No empty chips: only the categories among the feeds the kind lets through. const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort(); if(!cats.includes(dirCat)) dirCat=null; bar.innerHTML= (both?`
${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}
`:'')+ (cats.length?`
${cats.map(c=>btn('cat',c,c===dirCat)).join('')}
`:''); // A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to // the button just pressed. for(const b of $$('button',bar)) b.onclick=()=>{ const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k]; if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v; draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus(); }; box.innerHTML=''; for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile')); }; draw(); return rows.length; } /// Directory and Popular open in the main pane, as the original's Directory did. async function renderListed(v){ const box=$('#content'); box.classList.add('plain'); $('#tbRemove').disabled=true; syncTools(null); $('#epSearch').placeholder='Search items…'; const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory']; box.innerHTML=`
${v.icon}

${v.title}

${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}
${grid?'
':''}

Loading…

`; $('#count').textContent=v.title; const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box)); if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`; } /// Currently Listening: episodes you started and have not finished, across every feed you /// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to /// where you left off, not another way to browse. The one in the player pauses instead. async function renderListening(url,box){ let rows=[]; try{ rows=(await api(url)).entries||[]; }catch{} box.innerHTML=rows.length?'':'

Nothing in progress. Episodes you start and do not finish show up here.

'; for(const e of rows){ // Carries its episode, for paintListenRow to repaint as the player moves. const el: HTMLDivElement & {entry?: any}=document.createElement('div'); el.className='childrow'; el.entry=e; el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+ `
${EQ}${esc(e.title||'(untitled)')}`+ `${esc(feedName(e.feed_id))}
`+ ``+ ``+ `
`; el.onclick=ev=>(ev.target as Element).closest('[data-a=remove]')?forget(e) :el.classList.contains('now')&&!audio.paused?audio.pause():play(e); paintListenRow(el); box.appendChild(el); } return rows.length; } /// One row's time left, progress and play button, taken from the player when it is the one in it. function paintListenRow(el){ const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id; // Zero until the player has sought to where you left off; the saved position stands till then. if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime); // The player's own length first: a feed's can be minutes out. const d=(now&&isFinite(audio.duration)&&Math.floor(audio.duration))||e.duration; el.classList.toggle('now',now); $('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`; // With no length there is nothing to show, and an empty rail reads as a heavy border. const rail=$('.rail',el); rail.hidden=!d; $('i',rail).style.width=`${d?Math.min(100,e.position/d*100):0}%`; const b=$('[data-a=play]',el), label=now&&!audio.paused?'Pause':'Resume'; if(b.title!==label){ b.title=label; b.setAttribute('aria-label',label); b.innerHTML=label==='Pause'?ICON.pause:ICON.play; } } /// Keeps the list in step with the player. Only a row that is, or was, the one in it changes. function syncListening(){ for(const el of $$('#listening .childrow')) if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el); } /// Takes an episode off Currently Listening by forgetting where you got to: the list is every /// episode with a saved position short of the end, so the position is what has to go. async function forget(e){ // Closed without saving first, or the player's next save would put it straight back. if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); } try{ await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`, {method:'POST',body:JSON.stringify({secs:0})}); }catch(err){ toast(err.message,true); } if(S.feed===':listening') renderListed(VIEWS[':listening']); } // The toolbar acts on whatever is selected: the feed on the left, the item in the table. $('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); }; $('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); }; $('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); }; $('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); }; let searchT; $('#epSearch').oninput=ev=>{ clearTimeout(searchT); searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; loadEntries(); },250); }; // Crossing the phone breakpoint moves the files between their pane and the text. window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); }); let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]')); function toggleGroup(id){ expanded.has(id) ? expanded.delete(id) : expanded.add(id); try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{} renderFeeds(); } let globalMax = 3; function due(ts){ const d = ts - Date.now()/1000; if(d <= 0) return 'due now'; if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m'; if(d < 86400) return 'in '+Math.round(d/3600)+'h'; return 'in '+Math.round(d/86400)+'d'; } /// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does /// with feeds. The server's own settings, the accounts and the log are on /admin, which only an /// admin is sent (issue #19); this used to hold them too, shown to admins only. async function prefsModal(){ const g = await api('/api/settings'); const admin = !!(S.me&&S.me.admin); openModal(`

Settings

Auto follows your system's light/dark setting.
${ICON.save} Export
Export saves your subscriptions as OPML for another podcast app. Import subscribes you to every feed in one.
${everyText(g.every_mins)}, for every feed that does not set its own. ${admin?'This and the rest of the server\'s settings are on the admin page.':'Only an admin changes this.'}
${esc(g.download_dir)}
`); $('#stheme').onchange=e=>setTheme(e.target.value,undefined,true); $('#smode').onchange=e=>setTheme(undefined,e.target.value,true); $('#gopml').onclick=opmlModal; } function settingsModal(f, newUrl?: string){ const isGroup = S.feeds.some(c=>c.group===f.id); openModal(`

${esc(f.title||f.id)}

${isGroup?`

This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These settings apply to it and are inherited by every feed inside it.

`:''} ${f.managed?`

This feed comes from ${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in config.toml, and it stops following the subscription's settings.

`:''}

These are your settings for this feed. Everyone else keeps their own.

Comma separated. Empty takes everything.
Blank follows the global default (${globalMax}). The rest wait for the next scan.
${S.me&&S.me.admin ? `Shared with everyone reading this feed. Editing it keeps every item and download — handy when an auth token in the URL is rotated. The feed is re-checked from scratch on the next scan.` : `The same for everyone reading this feed, so only an admin can change it.`}
${S.me&&S.me.admin?`
Where the files land. There is one copy however many people subscribe, so this is the same for everyone.
`:''} ${S.me&&S.me.admin?`
${f.feed_category ? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.` : `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}
`:''}
`); $('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy')); // Offer the categories the Directory already shows, so a blog about games joins Games rather // than starting a second chip beside it. if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[]) .map(p=>p.category).filter(Boolean))].sort().map(c=>`