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
715 lines
34 KiB
HTML
715 lines
34 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|