Files
ipodderx-rs/web/index.html
rays 5666166769 Full-featured web UI
Rewrites the page around a persistent player (speed, seek, resume,
MediaSession, keyboard shortcuts), artwork, filter tabs, episode search,
pagination and live progress, with modals and toasts replacing prompt()
and a status line.

Backend gains the metadata that makes that possible: feed and episode
artwork, durations, season/episode numbers and playback position, plus
filters, search, totals, mark-all-read, download-latest and OPML over
HTTP. Schema changes arrive through a real migration, since CREATE TABLE
IF NOT EXISTS does nothing to an installed database.

Fixes filtering, which returned 500 whenever no search term was given:
the search clause was dropped while its parameter was still bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 01:42:55 +00:00

715 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>ipx</title>
<style>
:root {
--bg:#0f1115; --panel:#171a21; --panel2:#1e222b; --raise:#262b36;
--line:#2b313c; --fg:#e8eaf0; --dim:#98a1b3; --faint:#6b7385;
--accent:#6ea8fe; --accent2:#8b7cf6; --good:#4ade80; --bad:#f87171; --warn:#fbbf24;
--shadow:0 8px 28px rgba(0,0,0,.45);
--r:10px;
}
:root[data-theme="light"] {
--bg:#f6f7f9; --panel:#fff; --panel2:#f0f2f6; --raise:#e6e9ef;
--line:#dde1e9; --fg:#12151c; --dim:#5b6474; --faint:#8b93a4;
--shadow:0 8px 28px rgba(20,25,40,.12);
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;background:var(--bg);color:var(--fg);
font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:grid;grid-template-rows:1fr auto;height:100vh;overflow:hidden;
}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
a{color:var(--accent)}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:var(--raise);border-radius:6px}
::-webkit-scrollbar-track{background:transparent}
/* ---------- shell ---------- */
#shell{display:grid;grid-template-columns:290px 1fr;min-height:0;overflow:hidden}
#sidebar{
background:var(--panel);border-right:1px solid var(--line);
display:flex;flex-direction:column;min-height:0;
}
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px}
.brand .logo{
width:26px;height:26px;border-radius:7px;flex:none;
background:linear-gradient(135deg,var(--accent),var(--accent2));
display:grid;place-items:center;font-weight:800;font-size:13px;color:#0b0e14;
}
.brand h1{font-size:15px;margin:0;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);flex:1}
.iconbtn{
width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
color:var(--dim);flex:none;
}
.iconbtn:hover{background:var(--raise);color:var(--fg)}
.sidetools{display:flex;gap:6px;padding:0 12px 10px}
.sidetools button{
flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 8px;font-size:12.5px;color:var(--dim);
}
.sidetools button:hover{border-color:var(--accent);color:var(--fg)}
.searchwrap{padding:0 12px 8px}
input[type=search],input[type=text],input[type=number],select{
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
}
input:focus,select:focus{outline:0;border-color:var(--accent)}
#feedlist{overflow-y:auto;padding:0 8px 12px;flex:1;min-height:0}
.feed{
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
cursor:pointer;margin-bottom:1px;
}
.feed:hover{background:var(--panel2)}
.feed.sel{background:var(--raise)}
.art{
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
display:grid;place-items:center;color:var(--faint);font-weight:700;overflow:hidden;
}
.feed .art{width:38px;height:38px;font-size:14px}
.feed .txt{min-width:0;flex:1}
.feed .txt b{display:block;font-weight:600;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.feed .txt small{color:var(--faint);font-size:11.5px}
.badge{
background:var(--accent);color:#0b0e14;border-radius:20px;padding:1px 7px;
font-size:11px;font-weight:700;flex:none;
}
.badge.zero{background:var(--raise);color:var(--faint)}
/* ---------- main ---------- */
#main{overflow-y:auto;min-height:0;scroll-behavior:smooth}
.wrap{max-width:1000px;margin:0 auto;padding:18px 22px 40px}
.fhead{display:flex;gap:18px;margin-bottom:18px}
.fhead .art{width:118px;height:118px;font-size:34px;box-shadow:var(--shadow)}
.fhead .meta{min-width:0;flex:1;display:flex;flex-direction:column}
.fhead h2{margin:0 0 3px;font-size:23px;line-height:1.2}
.fhead .sub{color:var(--dim);font-size:13px;margin-bottom:8px}
.fhead .sub a{color:var(--dim);text-decoration:none}
.fhead .sub a:hover{color:var(--accent)}
.acts{display:flex;gap:7px;flex-wrap:wrap;margin-top:auto}
.btn{
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 12px;font-size:13px;
}
.btn:hover{border-color:var(--accent)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#0b0e14;font-weight:600}
.btn.primary:hover{filter:brightness(1.08)}
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
.toolbar{
display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap;
position:sticky;top:0;background:var(--bg);padding:6px 0 8px;z-index:3;
}
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:9px;padding:3px}
.tabs button{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim)}
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:600}
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
/* ---------- episodes ---------- */
.ep{
display:flex;gap:12px;padding:11px;border-radius:var(--r);
border:1px solid transparent;margin-bottom:3px;position:relative;
}
.ep:hover{background:var(--panel)}
.ep.playing{background:var(--panel);border-color:var(--accent)}
.ep .art{width:52px;height:52px;font-size:16px;cursor:pointer;position:relative}
.ep .art .ovl{
position:absolute;inset:0;display:grid;place-items:center;
background:rgba(0,0,0,.5);opacity:0;transition:opacity .12s;color:#fff;
}
.ep .art:hover .ovl,.ep.playing .art .ovl{opacity:1}
.ep .body{flex:1;min-width:0}
.ep .t{font-weight:600;font-size:14.5px;cursor:pointer;display:block}
.ep.read .t{color:var(--dim);font-weight:500}
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:12px;margin-top:2px}
.dot{width:3px;height:3px;border-radius:50%;background:var(--faint);flex:none}
.chip{
font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;font-weight:700;
padding:1px 6px;border-radius:5px;background:var(--raise);color:var(--dim);
}
.chip.done{background:rgba(74,222,128,.14);color:var(--good)}
.chip.error{background:rgba(248,113,113,.14);color:var(--bad)}
.chip.pending{background:rgba(251,191,36,.13);color:var(--warn)}
.chip.new{background:var(--accent);color:#0b0e14}
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
.ep:hover .rowacts,.ep.open .rowacts{opacity:1}
.notes{
margin-top:10px;padding:11px 13px;background:var(--panel2);border-radius:9px;
color:var(--dim);font-size:13.5px;overflow-wrap:anywhere;
}
.notes img{max-width:100%;height:auto;border-radius:6px}
.notes p:first-child{margin-top:0}.notes p:last-child{margin-bottom:0}
.dlbar{height:3px;background:var(--raise);border-radius:2px;overflow:hidden;margin-top:7px}
.dlbar i{display:block;height:100%;width:0;background:var(--accent);transition:width .25s}
.empty{color:var(--faint);text-align:center;padding:50px 0}
#more{display:block;width:100%;margin-top:10px}
/* ---------- player ---------- */
#player{
border-top:1px solid var(--line);background:var(--panel);
display:none;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;
padding:9px 16px;box-shadow:0 -6px 24px rgba(0,0,0,.28);
}
#player.on{display:grid}
#pnow{display:flex;gap:11px;align-items:center;min-width:0;width:250px}
#pnow .art{width:44px;height:44px;font-size:13px}
#pnow .txt{min-width:0}
#pnow b{display:block;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#pnow small{color:var(--faint);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
#pmid{display:flex;flex-direction:column;gap:3px;min-width:0}
#pbtns{display:flex;gap:5px;align-items:center;justify-content:center}
#pbtns .iconbtn{width:34px;height:34px}
#pplay{
width:40px!important;height:40px!important;background:var(--fg);color:var(--bg);
border-radius:50%;
}
#pplay:hover{background:var(--accent);color:#0b0e14}
#seekrow{display:flex;gap:9px;align-items:center;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--faint)}
input[type=range]{
-webkit-appearance:none;appearance:none;height:4px;border-radius:3px;flex:1;
background:var(--raise);cursor:pointer;
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:var(--accent);
}
input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radius:50%;background:var(--accent)}
#pright{display:flex;gap:8px;align-items:center}
#pright select{width:auto;padding:4px 6px;font-size:12px}
#vol{width:78px;flex:none}
/* ---------- overlays ---------- */
#modal{
position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;
place-items:center;z-index:50;padding:20px;
}
#modal.on{display:grid}
.card{
background:var(--panel);border:1px solid var(--line);border-radius:14px;
padding:20px;width:min(460px,100%);box-shadow:var(--shadow);max-height:88vh;overflow:auto;
}
.card h3{margin:0 0 14px;font-size:17px}
.field{display:grid;gap:4px;margin-bottom:12px}
.field label{font-size:12px;color:var(--dim)}
.field .hint{font-size:11.5px;color:var(--faint)}
.check{display:flex;gap:8px;align-items:center;font-size:13.5px;margin-bottom:9px}
.check input{width:16px;height:16px;accent-color:var(--accent)}
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
.toast{
background:var(--raise);border:1px solid var(--line);border-radius:9px;
padding:9px 13px;font-size:13px;box-shadow:var(--shadow);animation:in .18s;max-width:340px;
}
.toast.bad{border-color:var(--bad);color:var(--bad)}
@keyframes in{from{opacity:0;transform:translateY(6px)}}
#burger{display:none}
@media (max-width:820px){
#shell{grid-template-columns:1fr}
#sidebar{position:fixed;inset:0 auto 0 0;width:280px;z-index:40;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
#sidebar.open{transform:none}
#burger{display:grid}
.fhead .art{width:78px;height:78px;font-size:24px}
.fhead h2{font-size:19px}
#pnow{width:auto;flex:1}
#pnow .txt small,#pright #vol{display:none}
.wrap{padding:14px}
}
</style>
</head>
<body>
<div id="shell">
<aside id="sidebar">
<div class="brand">
<div class="logo">ix</div><h1>ipx</h1>
<button class="iconbtn" id="theme" title="Toggle theme">◐</button>
</div>
<div class="sidetools">
<button id="addFeed">+ Feed</button>
<button id="scanAll">Scan all</button>
<button id="opml" title="Import / export OPML">OPML</button>
</div>
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
<div id="feedlist"></div>
</aside>
<div id="main"><div class="wrap" id="content"></div></div>
</div>
<div id="player">
<div id="pnow">
<button class="iconbtn" id="burger" title="Feeds">☰</button>
<div id="partwrap"></div>
<div class="txt"><b id="ptitle"></b><small id="pfeed"></small></div>
</div>
<div id="pmid">
<div id="pbtns">
<button class="iconbtn" id="pback" title="Back 15s (←)">↺15</button>
<button class="iconbtn" id="pplay" title="Play/pause (space)">▶</button>
<button class="iconbtn" id="pfwd" title="Forward 30s (→)">30↻</button>
</div>
<div id="seekrow">
<span id="pcur">0:00</span>
<input type="range" id="seek" min="0" max="1000" value="0">
<span id="pdur">0:00</span>
</div>
</div>
<div id="pright">
<select id="rate" title="Speed">
<option value="0.8">0.8×</option><option value="1" selected>1×</option>
<option value="1.25">1.25×</option><option value="1.5">1.5×</option>
<option value="1.75">1.75×</option><option value="2">2×</option><option value="2.5">2.5×</option>
</select>
<input type="range" id="vol" min="0" max="100" value="100" title="Volume">
<button class="iconbtn" id="pclose" title="Close">✕</button>
</div>
</div>
<div id="modal"><div class="card" id="modalCard"></div></div>
<div id="toasts"></div>
<audio id="audio" preload="metadata"></audio>
<script>
'use strict';
const $ = (s,r=document)=>r.querySelector(s);
const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function api(url,opts){
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
if(!r.ok) throw new Error(await r.text().catch(()=>r.status)||r.status);
return r.status===204?null:r.json().catch(()=>null);
}
function toast(msg,bad){
const t=document.createElement('div');
t.className='toast'+(bad?' bad':''); t.textContent=msg;
$('#toasts').appendChild(t);
setTimeout(()=>{t.style.opacity=0;t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
}
const clock = s => {
s=Math.max(0,Math.floor(s||0));
const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;
return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;
};
const ago = t => {
if(!t) return 'never';
const d=(Date.now()/1000)-t;
if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
if(d<86400) return Math.round(d/3600)+'h ago';
if(d<2592000) return Math.round(d/86400)+'d ago';
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
};
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
const mb = n => n?(n/1048576).toFixed(0)+' MB':'';
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
function artHTML(url,name,cls){
return url
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'art ${cls||''}',textContent:'${esc(initials(name))}'}))">`
: `<div class="art ${cls||''}">${esc(initials(name))}</div>`;
}
/* ---------------- state ---------------- */
const S = {
feeds:[], feed:null, entries:[], total:0, offset:0, limit:50,
filter:'unread', q:'', open:new Set(), busy:new Set(),
};
const LIMIT = 50;
/* ---------------- feeds ---------------- */
async function loadFeeds(keepSel){
S.feeds = await api('/api/feeds');
renderFeeds();
if(!keepSel && !S.feed && S.feeds.length) selectFeed(S.feeds[0].id);
}
function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); list.innerHTML='';
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
if(!shown.length){ list.innerHTML='<p class="empty" style="padding:20px 8px">No feeds.</p>'; return; }
for(const f of shown){
const el=document.createElement('div');
el.className='feed'+(S.feed===f.id?' sel':'');
el.innerHTML = artHTML(f.image,f.title||f.id)+
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>${f.entries} eps · ${f.downloaded} saved</small></div>`+
`<span class="badge${f.unread?'':' zero'}">${f.unread}</span>`;
el.onclick=()=>{ selectFeed(f.id); $('#sidebar').classList.remove('open'); };
list.appendChild(el);
}
}
function selectFeed(id){
S.feed=id; S.offset=0; S.open.clear(); S.q='';
renderFeeds(); renderFeed(); loadEntries();
}
/* ---------------- feed page ---------------- */
function renderFeed(){
const f=S.feeds.find(x=>x.id===S.feed);
if(!f){ $('#content').innerHTML='<p class="empty">Add a feed to get started.</p>'; return; }
$('#content').innerHTML = `
<div class="fhead">
${artHTML(f.image,f.title||f.id)}
<div class="meta">
<h2>${esc(f.title||f.id)}</h2>
<div class="sub">${f.entries} episodes · ${f.downloaded} downloaded · checked ${ago(f.last_checked)}</div>
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
<div class="acts">
<button class="btn primary" data-a="scan">Scan now</button>
<button class="btn" data-a="dl">Download latest…</button>
<button class="btn" data-a="read">Mark all read</button>
<button class="btn" data-a="settings">Settings</button>
<button class="btn danger" data-a="rm">Unsubscribe</button>
</div>
</div>
</div>
<div class="toolbar">
<div class="tabs">
${['unread','all','downloaded','flagged'].map(t=>
`<button data-f="${t}" class="${S.filter===t?'on':''}">${t[0].toUpperCase()+t.slice(1)}</button>`).join('')}
</div>
<input type="search" class="grow" id="epSearch" placeholder="Search episodes…" value="${esc(S.q)}">
<span style="color:var(--faint);font-size:12.5px" id="count"></span>
</div>
<div id="eps"></div>`;
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
$$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()});
let t; $('#epSearch').oninput=e=>{clearTimeout(t);t=setTimeout(()=>{S.q=e.target.value;S.offset=0;loadEntries()},250)};
}
async function feedAction(a,f){
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); loadEntries(); }
if(a==='rm') removeFeed(f);
if(a==='settings') settingsModal(f);
if(a==='dl') downloadLatestModal(f);
}
/* ---------------- episodes ---------------- */
async function loadEntries(append){
if(!S.feed) return;
const p=new URLSearchParams({offset:S.offset,limit:LIMIT,filter:S.filter});
if(S.q) p.set('q',S.q);
const r=await api(`/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
S.total=r.total;
S.entries = append ? S.entries.concat(r.entries) : r.entries;
renderEntries();
}
function renderEntries(){
const box=$('#eps'); if(!box) return;
const c=$('#count'); if(c) c.textContent=`${S.total} episode${S.total===1?'':'s'}`;
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));
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){
const enc=e.enclosures[0];
const has=!!(enc&&enc.path);
const el=document.createElement('div');
el.className='ep'+(e.read?' read':'')+(S.open.has(e.guid)?' open':'')+
(player.guid===e.guid?' playing':'');
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=`
${artHTML(e.image||feedArt(),e.title,'')}
<div class="body">
<span class="t">${esc(e.title||'(untitled)')}</span>
<div class="line">
${e.read?'':'<span class="chip new">new</span>'}
${num?`<span>${num}</span><span class="dot"></span>`:''}
<span>${dateOf(e.published)}</span>
${left?'<span class="dot"></span><span>'+left+'</span>':''}
${enc?`<span class="dot"></span><span class="chip ${esc(enc.state)}">${has?'downloaded':esc(enc.state)}</span>`:''}
${enc&&enc.length?`<span>${mb(enc.length)}</span>`:''}
${e.flagged?'<span class="dot"></span><span>★ kept</span>':''}
</div>
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
${enc&&enc.last_error?`<div class="line" style="color:var(--bad)">${esc(enc.last_error)}</div>`:''}
</div>
<div class="rowacts">
${has?`<button class="iconbtn" data-a="play" title="Play">▶</button>`:
(enc?`<button class="iconbtn" data-a="get" title="Download">⤓</button>`:'')}
<button class="iconbtn" data-a="flag" title="${e.flagged?'Stop keeping':'Keep (never auto-delete)'}">${e.flagged?'★':'☆'}</button>
<button class="iconbtn" data-a="read" title="Mark ${e.read?'unread':'read'}">${e.read?'○':'●'}</button>
${has?`<button class="iconbtn" data-a="del" title="Delete file">🗑</button>`:''}
<button class="iconbtn" data-a="notes" title="Show notes">≡</button>
</div>`;
const art=$('.art',el);
if(art&&has) art.insertAdjacentHTML('beforeend','<span class="ovl">▶</span>');
if(art&&has) art.onclick=()=>play(e);
$('.t',el).onclick=()=>toggleNotes(e,el);
$$('.rowacts .iconbtn',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
if(S.open.has(e.guid)) showNotes(e,el);
return el;
}
function feedArt(){ const f=S.feeds.find(x=>x.id===S.feed); return f&&f.image; }
function toggleNotes(e,el){
if(S.open.has(e.guid)){ S.open.delete(e.guid); const n=$('.notes',el); if(n)n.remove(); el.classList.remove('open'); }
else { S.open.add(e.guid); showNotes(e,el); el.classList.add('open'); }
}
function showNotes(e,el){
if($('.notes',el)) return;
const d=document.createElement('div');
d.className='notes';
// Sanitized server-side with ammonia before it ever reaches the browser.
d.innerHTML=(e.description&&e.description.trim())||'<em>No show notes.</em>';
if(e.link) d.insertAdjacentHTML('beforeend',`<p><a href="${esc(e.link)}" target="_blank" rel="noopener noreferrer">Open original ↗</a></p>`);
$('.body',el).appendChild(d);
}
async function epAction(a,e,el){
const enc=e.enclosures[0];
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{
if(a==='play') play(e);
if(a==='notes') toggleNotes(e,el);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); el.replaceWith(epEl(e)); }
if(a==='read'){ e.read=!e.read; await api(path+'/flags',{method:'POST',body:JSON.stringify({read:e.read})}); el.replaceWith(epEl(e)); loadFeeds(true); }
if(a==='get'){
if(!enc) return;
S.busy.add(enc.id);
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
toast('Queued: '+(e.title||'episode'));
}
if(a==='del'){
if(!confirm('Delete the downloaded file?\n\nThe episode stays listed and will not be downloaded again automatically.')) return;
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
toast('Deleted'); loadEntries(); loadFeeds(true);
}
}catch(err){ toast(err.message,true); }
}
/* ---------------- player ---------------- */
const audio=$('#audio');
const player={guid:null,feed:null,entry:null,saveAt:0};
function play(e){
const enc=e.enclosures.find(x=>x.path);
if(!enc){ toast('Not downloaded yet',true); return; }
const resuming = player.guid===e.guid;
if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e;
audio.src=`/media/${enc.id}`;
audio.currentTime=0;
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
$('#partwrap').innerHTML=artHTML(e.image||feedArt(),e.title);
$('#ptitle').textContent=e.title||'(untitled)';
const f=S.feeds.find(x=>x.id===e.feed_id);
$('#pfeed').textContent=f?(f.title||f.id):'';
$('#player').classList.add('on');
mediaSession(e,f);
// Playing it is the clearest signal it has been listened to; retention deletes
// read episodes before unread ones.
if(!e.read){ e.read=true; api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read:true})}).then(()=>loadFeeds(true)); }
}
audio.play().catch(err=>toast('Playback failed: '+err.message,true));
renderEntries();
}
function mediaSession(e,f){
if(!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata=new MediaMetadata({
title:e.title||'', artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
artwork:(e.image||(f&&f.image))?[{src:e.image||f.image,sizes:'512x512'}]:[],
});
const h={play:()=>audio.play(),pause:()=>audio.pause(),
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
for(const k in h){ try{navigator.mediaSession.setActionHandler(k,h[k])}catch{} }
}
audio.addEventListener('timeupdate',()=>{
const d=audio.duration||player.entry?.duration||0;
$('#pcur').textContent=clock(audio.currentTime);
$('#pdur').textContent=clock(d);
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
// Persist roughly every 10s so a reload resumes where you were.
if(player.guid && audio.currentTime-player.saveAt>10){ savePos(); }
});
function savePos(){
if(!player.guid) return;
player.saveAt=audio.currentTime;
if(player.entry) player.entry.position=Math.floor(audio.currentTime);
navigator.sendBeacon?.(
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position`,
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime)})],{type:'application/json'}));
}
audio.addEventListener('pause',savePos);
audio.addEventListener('ended',()=>{savePos();$('#pplay').textContent='▶'});
audio.addEventListener('play',()=>$('#pplay').textContent='❚❚');
audio.addEventListener('pause',()=>$('#pplay').textContent='▶');
window.addEventListener('beforeunload',savePos);
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$('#pback').onclick=()=>audio.currentTime-=15;
$('#pfwd').onclick=()=>audio.currentTime+=30;
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');renderEntries()};
(function restore(){
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
if(r){$('#rate').value=r;audio.playbackRate=+r}
if(v){$('#vol').value=v;audio.volume=v/100}
})();
document.addEventListener('keydown',ev=>{
if(/^(INPUT|TEXTAREA|SELECT)$/.test(ev.target.tagName)) return;
if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()}
else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15}
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
else if(ev.key==='Escape'){closeModal();$('#sidebar').classList.remove('open')}
});
/* ---------------- modals ---------------- */
function openModal(html){ $('#modalCard').innerHTML=html; $('#modal').classList.add('on'); }
function closeModal(){ $('#modal').classList.remove('on'); }
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); };
$('#addFeed').onclick=()=>{
openModal(`<h3>Add a feed</h3>
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss"></div>
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
<div class="field"><label>Keywords (optional, comma separated)</label>
<input type="text" id="nkw"><span class="hint">Only episodes matching a keyword are downloaded.</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="nsave">Add feed</button></div>`);
$('#nurl').focus();
$('#nsave').onclick=async()=>{
const url=$('#nurl').value.trim(); if(!url) return;
$('#nsave').textContent='Adding…'; $('#nsave').disabled=true;
try{
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
url, folder:$('#nfolder').value.trim()||null,
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').textContent='Add feed'; $('#nsave').disabled=false; }
};
};
function settingsModal(f){
openModal(`<h3>${esc(f.title||f.id)}</h3>
<div class="field"><label>Download folder</label>
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}"></div>
<div class="field"><label>Keywords</label>
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
<span class="hint">Comma separated. Empty takes everything.</span></div>
<div class="field"><label>Max new downloads per scan</label>
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
<span class="hint">Blank means no limit. The rest wait for the next scan.</span></div>
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new episodes automatically</label>
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow episodes marked explicit</label>
<div class="field"><label>Feed URL</label><span class="hint" style="overflow-wrap:anywhere">${esc(f.url)}</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="ssave">Save</button></div>`);
$('#ssave').onclick=async()=>{
const max=$('#smax').value;
try{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({
folder:$('#sfolder').value.trim()||null,
keywords:$('#skw').value.split(',').map(s=>s.trim()).filter(Boolean),
max_new_per_check:max===''?null:Number(max),
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked})});
closeModal(); toast('Saved — applies on the next scan');
await loadFeeds(true); renderFeed(); loadEntries();
}catch(e){ toast(e.message,true); }
};
}
function downloadLatestModal(f){
openModal(`<h3>Download latest episodes</h3>
<div class="field"><label>How many of the newest undownloaded episodes?</label>
<input type="number" id="dcount" min="1" max="100" value="5">
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="dgo">Download</button></div>`);
$('#dgo').onclick=async()=>{
const n=Number($('#dcount').value)||5;
try{
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
{method:'POST',body:JSON.stringify({count:n})});
closeModal(); toast(`Queued ${r.queued} episode${r.queued===1?'':'s'}`);
}catch(e){ toast(e.message,true); }
};
}
function removeFeed(f){
openModal(`<h3>Unsubscribe?</h3>
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds.
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn danger" id="rgo">Unsubscribe</button></div>`);
$('#rgo').onclick=async()=>{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
closeModal(); toast('Unsubscribed'); S.feed=null;
await loadFeeds(); if(!S.feeds.length) renderFeed();
};
}
$('#opml').onclick=()=>{
openModal(`<h3>OPML</h3>
<p style="color:var(--dim);font-size:13.5px">Move subscriptions between podcast apps.</p>
<div class="cardacts" style="justify-content:flex-start">
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml">Export</a>
</div>
<div class="field" style="margin-top:16px"><label>Import: paste OPML</label>
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Close</button>
<button class="btn primary" id="oimp">Import</button></div>`);
$('#oimp').onclick=async()=>{
try{
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml:$('#opmlText').value})});
closeModal(); toast(`Imported ${r.added} feed(s)`); loadFeeds(true);
}catch(e){ toast(e.message,true); }
};
};
$('#scanAll').onclick=async()=>{ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); };
$('#feedFilter').oninput=renderFeeds;
$('#burger').onclick=()=>$('#sidebar').classList.toggle('open');
$('#theme').onclick=()=>{
const cur=document.documentElement.dataset.theme==='light'?'dark':'light';
document.documentElement.dataset.theme=cur; localStorage.setItem('ipx.theme',cur);
};
if(localStorage.getItem('ipx.theme')) document.documentElement.dataset.theme=localStorage.getItem('ipx.theme');
/* ---------------- live events ---------------- */
let sse;
function connect(){
sse=new EventSource('/api/events');
sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){
const pct=ev.total?ev.done/ev.total*100:0;
$$('.dlbar').forEach(b=>{ /* progress applies to whatever is downloading now */
if(b.dataset.bar) b.firstElementChild.style.width=pct+'%';
});
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
}
else if(ev.ev==='download_done'){ toast('Downloaded '+ev.path.split('/').pop()); loadEntries(); loadFeeds(true); }
else if(ev.ev==='download_error'){ toast('Download failed: '+ev.msg,true); loadEntries(); }
else if(ev.ev==='feed_done'){ if(ev.new) toast(`${ev.feed}: ${ev.new} new`); loadFeeds(true); if(ev.feed===S.feed) loadEntries(); }
else if(ev.ev==='feed_error'){ toast(ev.feed+': '+ev.msg,true); loadFeeds(true); }
else if(ev.ev==='scan_done'){ loadFeeds(true); if(S.feed) loadEntries(); }
};
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
}
connect();
loadFeeds();
</script>
</body>
</html>