Files
ipodderx-rs/web/index.html
rays 57dcba2d1a One file icon, green when downloaded; mark unread is an envelope
- The separate check (and warning) beside a file's type icon is gone:
  the type icon itself is green once the file is downloaded and red when
  the download failed, with the details in its tooltip. One icon per row
  keeps the column lined up. On Classic's blue selection they are a
  lighter green and red rather than white.
- Mark unread under an item's title was a solid circle, which read as a
  record button. It is Font Awesome's closed envelope now.
- Drops the unused circle-check, circle-exclamation and circle icons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
2026-09-11 15:20:32 +00:00

1830 lines
146 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>iPodderX</title>
<link rel="icon" href="data:image/png;base64,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">
<style>
/* Palette taken from the 2004 iPodderX icon: the silver device body, the blue
screen, and the amber EQ bars. Hex values in comments are sampled straight from it. */
:root {
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
--panel:#151c27;
--panel2:#1c2431;
--raise:#25303f;
--line:#2c3849;
--fg:#f5f5f5; /* #F5F5F5 device highlight */
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
--accent:#92b2e6; /* #92B2E6 the screen blue */
--accent2:#f49e2c; /* #F49E2C the EQ bars */
--ink:#0e131b; /* text on an accent fill */
--good:#6fbf8b;
--warn:#f49e2c; /* the amber doubles as the pending colour */
--bad:#e2705f;
--shadow:0 8px 28px rgba(6,10,16,.55);
--r:10px;
}
:root[data-theme="light"] {
--bg:#f2f4f7;
--panel:#ffffff; /* #FFFFFF device body */
--panel2:#e9edf3;
--raise:#dde3ec;
--line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#b06f10;
--ink:#ffffff;
--good:#2f7d4f;
--warn:#b06f10;
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
}
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
:root[data-theme="classic"] {
color-scheme:light;
--bg:#ffffff;
--panel:#e7ebf1; /* the source list's pale blue-grey */
--panel2:#f2f2f2;
--raise:#d5dce7;
--line:#a9a9a9;
--fg:#000000;
--dim:#444444;
--faint:#666666;
--accent:#3875d7; /* Aqua selection blue */
--accent2:#2a5db0;
--ink:#ffffff;
--good:#237a23;
--warn:#a15f00;
--bad:#c42b1c;
--shadow:0 4px 16px rgba(0,0,0,.28);
--r:5px;
}
*{box-sizing:border-box}
/* A rule that sets display beats the UA's [hidden], and several below do. */
[hidden]{display:none!important}
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:auto 1fr auto 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;min-width: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:30px;height:30px;flex:none;object-fit:contain;
}
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);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)}
/* One toolbar across the window, as the original had: grouped buttons, search on the right. */
#topbar{
display:flex;align-items:center;gap:10px;padding:7px 12px;min-width:0;overflow:hidden;
background:var(--panel);border-bottom:1px solid var(--line);
}
#topbar .grow{flex:1}
#topbar #epSearch{width:260px;flex:none}
.tgroup{display:flex;flex:none;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel2)}
.tgroup button{padding:5px 11px;min-width:34px;color:var(--dim);font-size:14px;line-height:1.3}
.tgroup button+button{border-left:1px solid var(--line)}
.tgroup button:hover:not(:disabled){background:var(--raise);color:var(--fg)}
.tgroup button:disabled{opacity:.35;cursor:default}
.sidetools button,.sidefoot 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,.sidefoot button:hover{border-color:var(--accent);color:var(--fg)}
/* Settings and the log are housekeeping: they sit under the feeds, out of the way. */
.sidefoot{
display:flex;flex-direction:column;gap:7px;padding:8px 12px;
border-top:1px solid var(--line);flex:none;
}
.sidefoot .row{display:flex;gap:6px}
.who{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--faint)}
.who span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.who button{flex:none;background:none;border:0;padding:2px 4px;color:var(--faint);text-decoration:underline}
.who button:hover{color:var(--fg)}
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.sidefoot i{font-style:normal;opacity:.75}
.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)}
.feed.child{margin-left:16px}
.chev{
flex:none;width:18px;height:18px;display:grid;place-items:center;border-radius:4px;
color:var(--faint);font-size:11px;transition:transform .12s;
}
.chev:empty{pointer-events:none}
.chev:not(:empty):hover{background:var(--raise);color:var(--fg)}
.chev.open{transform:rotate(90deg)}
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
.childrow{
display:flex;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--line);
border-radius:9px;cursor:pointer;background:var(--panel);
}
.childrow:hover{border-color:var(--accent)}
.childrow .art{width:32px;height:32px;font-size:12px}
.childrow .txt{flex:1;min-width:0}
.childrow .txt b{display:block;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tag{
font-size:10px;text-transform:uppercase;letter-spacing:.04em;font-weight:700;
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
}
.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:34px;height:34px;font-size:13px}
.feed .txt{min-width:0;flex:1}
.feed .txt b{display:block;font-weight:600;font-size:13.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.feed .txt small{display:block;color:var(--faint);font-size:11.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.badge{
background:var(--accent);color:var(--ink);border-radius:20px;padding:1px 7px;
font-size:11px;font-weight:700;flex:none;min-width:26px;text-align:center;
}
.badge.zero{background:var(--raise);color:var(--faint)}
/* ---------- main ---------- */
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
.wrap{flex:1;min-height:0;min-width:0;display:flex;flex-direction:column}
.wrap.plain{overflow-y:auto;padding:18px 22px 40px;display:block}
#content{display:flex;flex-direction:column;min-height:0;min-width:0;flex:1}
#content>.fhead,#content>.toolbar{padding-left:20px;padding-right:20px;flex:none}
#content>.fhead{padding-top:16px}
/* Three panes, as the original had: feeds beside, items above, the item below. */
#split{
display:grid;flex:1;min-height:0;
grid-template-columns:minmax(0,1fr) 270px;
grid-template-rows:minmax(90px,var(--listh,40%)) 7px 1fr;
grid-template-areas:"list files" "grab grab" "detail detail";
}
#list{grid-area:list;overflow:auto;padding:0 14px 10px}
/* The selected item's files, beside the list, as the original's Files pane was. */
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
#files .encbox{margin-top:8px}
#files .encbox audio{min-width:0;width:100%;flex-basis:100%}
#files .empty{padding:24px 0}
.fhd{font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint)}
#grab{grid-area:grab;cursor:row-resize;background:var(--line)}
#grab:hover{background:var(--accent)}
#detail{grid-area:detail;overflow-y:auto;padding:16px 20px 28px;background:var(--panel);border-top:1px solid var(--line)}
.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3;overflow-wrap:anywhere}
.dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
.dmeta .btn{padding:3px 9px;font-size:12px}
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
.dbody img{max-width:100%;height:auto;border-radius:6px}
.dbody a{color:var(--accent)}
.dbody pre{overflow-x:auto;background:var(--panel2);padding:10px;border-radius:8px}
.encbox{
display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px;
padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);
}
.encbox audio{flex:1;min-width:200px;margin:0}
.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;overflow-wrap:anywhere}
.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;align-items:center;margin-top:auto}
.acts .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px}
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
.acts .btn.primary i{opacity:.9}
.acts .btn:hover{background:var(--raise)}
.acts .btn.primary:hover{background:var(--accent2);border-color:var(--accent2)}
/* Leaving a feed is not one of the everyday actions: keep it off on its own. */
.acts .btn.last{margin-left:auto;border-color:transparent;background:none;color:var(--faint)}
.acts .btn.last:hover{background:none;border-color:var(--bad);color:var(--bad)}
.btn{
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 12px;font-size:13px;
}
.btn:hover{border-color:var(--accent)}
a.btn{text-decoration:none;color:inherit}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--ink);font-weight:600}
.btn.primary:hover{filter:brightness(1.08)}
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
/* An icon in place of a word; the word is in its tooltip. */
.btn.ico{padding:4px 9px;min-width:32px;font-size:14px;line-height:1.25;text-align:center}
/* An icon (Font Awesome, embedded as SVG) in the button's own colour. */
.i{display:inline-block;width:16px;height:16px;vertical-align:-3px;flex:none;fill:currentColor}
/* A file's type as an icon, in place of the old DOWNLOADED / PENDING / audio chips: green once
it is here, red when the download failed. */
.kind{display:inline-grid;place-items:center;color:var(--dim)}
.kind.here{color:var(--good)}
.kind.bad{color:var(--bad)}
.fhead .art .i{width:22px;height:22px}
.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}
/* ---------- items ---------- */
/* A table, as the original's was: unread, kept, the item, its feed, its file, when. */
.ephead,.ep{
display:grid;gap:8px;align-items:center;
grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,160px) minmax(0,112px) minmax(0,92px) 64px;
}
.ephead{
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 10px 5px;
border-bottom:1px solid var(--line);margin-bottom:3px;
font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);
}
.ep{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px}
.ep>*{min-width:0}
.ep.sel{background:var(--raise);border-color:var(--line)}
.ep:hover{background:var(--panel)}
.ep.playing{border-color:var(--accent)}
.ep .st,.ep .fl{width:22px;height:22px;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent)}
.ep .fl{color:var(--faint);font-size:13px}
.ep .fl.on{color:var(--accent2)}
.ep .st:hover,.ep .fl:hover{background:var(--raise)}
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.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:11.5px}
.ep .line:empty{display:none}
.ep .fd,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep .file{display:flex;gap:6px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
.ep .file .dlbar{flex-basis:100%;margin-top:2px}
.ep .rowacts{justify-content:flex-end}
/* One feed's own table has no need of a Feed column; All Subscriptions does. */
#split.one .ephead,#split.one .ep{grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,112px) minmax(0,92px) 64px}
#split.one .h-fd,#split.one .ep .fd{display:none}
.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:color-mix(in srgb,var(--good) 16%,transparent);color:var(--good)}
.chip.error{background:color-mix(in srgb,var(--bad) 16%,transparent);color:var(--bad)}
.chip.pending{background:color-mix(in srgb,var(--warn) 16%,transparent);color:var(--warn)}
.chip.new{background:var(--accent);color:var(--ink)}
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
.ep:hover .rowacts,.ep.open .rowacts{opacity:1}
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
.dlbar.live{background:var(--raise)}
.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(6,10,16,.4);
}
#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:var(--ink)}
#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.wide{width:min(1000px,100%)}
#logbox{
background:var(--bg);border:1px solid var(--line);border-radius:9px;padding:10px 12px;
height:min(60vh,520px);overflow:auto;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
}
#logbox .l{display:flex;gap:9px;white-space:pre-wrap;overflow-wrap:anywhere}
#logbox time{color:var(--faint);flex:none}
#logbox .lv{flex:none;width:42px;font-weight:700}
#logbox .lv.ERROR{color:var(--bad)} #logbox .lv.WARN{color:var(--warn)}
#logbox .lv.INFO{color:var(--accent)} #logbox .lv.DEBUG,#logbox .lv.TRACE{color:var(--faint)}
#logbox .tg{color:var(--faint);flex:none}
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
.logbar .grow{flex:1;min-width:120px}
.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}
.inline{display:flex;gap:6px;align-items:stretch}
.inline input{flex:1;min-width:0}
.inline .btn{white-space:nowrap;flex:none}
.inline select{flex:none;width:auto}
.inline input[type=number]{flex:none;width:90px}
.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,#dback{display:none}
/* Totals for what is showing, along the bottom, as the original's status bar. */
#status{
padding:3px 14px;min-height:22px;font-size:12px;color:var(--faint);background:var(--panel);
border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
/* The feed's own header, kept to one line so the table starts high, as it did. */
.fhead.slim{align-items:center;gap:8px 12px;margin-bottom:10px;flex-wrap:wrap}
.fhead.slim .art{width:44px;height:44px;font-size:15px;box-shadow:none}
.fhead.slim .meta{flex:1 1 260px}
/* One line each, or a long title wraps to three and pushes the table down. */
.fhead.slim h2,.fhead.slim .sub.stat{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fhead.slim h2{font-size:17px}
.fhead.slim .sub{margin-bottom:0;font-size:12.5px}
.fhead.slim .acts{margin-top:0;flex:none}
.fhead.slim .acts .btn{padding:5px 11px;font-size:12.5px}
.fhead.slim .acts .btn.last{margin-left:0}
/* Places above the feeds: not subscriptions, but where to look. */
.places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px}
.place{display:flex;gap:10px;align-items:center;padding:6px 8px;border-radius:9px;cursor:pointer}
.place:hover{background:var(--panel2)}
.place.sel{background:var(--raise)}
.place .ico{width:18px;text-align:center;color:var(--accent);flex:none}
.place b{flex:1;font-weight:600;font-size:13.5px}
@media (max-width:820px){
#shell{grid-template-columns:1fr}
#sidebar{position:fixed;inset:0 auto 0 0;width:min(300px,86vw);z-index:40;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
#sidebar.open{transform:none}
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
#sidebar{z-index:42}
#player{position:relative;z-index:39;padding:7px 10px;gap:8px}
/* The feed list is reachable whether or not anything is playing. */
#burger{display:grid}
#topbar{gap:6px;padding:6px 8px}
#topbar .grow,.tgroup.item{display:none}
#topbar #epSearch{width:auto;flex:1;min-width:0}
.tgroup button{padding:4px 8px;min-width:32px}
.fhead.slim{flex-wrap:wrap}
.fhead.slim .acts{flex:1 1 100%}
.iconbtn{width:38px;height:38px}
/* One pane at a time: the list, then the item over it. */
#split{grid-template-columns:1fr;grid-template-rows:1fr;grid-template-areas:"list"}
/* Title and date only; the files follow the item's text in the reader instead. */
#files,.ephead,.ep .fd,.ep .file,.ep .rowacts{display:none}
.ep,#split.one .ep{grid-template-columns:20px 20px minmax(0,1fr) auto}
#grab{display:none}
#detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px}
body.reading #detail{display:block}
#dback{display:inline-block;margin-bottom:10px}
#content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px}
#content>.fhead{padding-top:12px}
.fhead{gap:12px;margin-bottom:12px}
.fhead .art{width:64px;height:64px;font-size:20px}
.fhead h2{font-size:18px}
.fhead .sub{font-size:12px;margin-bottom:6px}
.acts{gap:6px}
.acts .btn{padding:7px 10px;font-size:12.5px}
.toolbar{gap:8px}
#list{padding:0 12px 10px}
.ep .art{width:44px;height:44px;font-size:14px}
#player{grid-template-columns:1fr auto;grid-template-areas:"now right" "mid mid";gap:2px 8px}
#pnow{grid-area:now;width:auto}
#pright{grid-area:right}
#pmid{grid-area:mid}
#pnow .art{width:38px;height:38px}
#pnow .txt small,#pright #vol{display:none}
.wrap.plain{padding:14px}
.card{padding:16px}
/* A log line has no room for four columns: keep time and message, wrap as prose. */
#logbox{font-size:11.5px;padding:8px}
#logbox .l{flex-wrap:wrap;gap:6px}
#logbox .lv{width:auto}
#logbox .tg{display:none}
}
/* ---------- Classic: the 2004 Mac app ---------- */
/* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue
for whatever is selected, red unread badges, a striped table and Lucida Grande. */
:root[data-theme="classic"] body{
font-family:"Lucida Grande","Lucida Sans Unicode","Lucida Sans",Geneva,Verdana,sans-serif;font-size:13px;
}
:root[data-theme="classic"] #topbar,
:root[data-theme="classic"] #status,
:root[data-theme="classic"] #player{background:linear-gradient(#ececec,#c9c9c9);border-color:#8e8e8e;color:#1a1a1a}
:root[data-theme="classic"] #status{text-align:center}
:root[data-theme="classic"] #sidebar{background:#e7ebf1;border-right-color:#a3a3a3}
:root[data-theme="classic"] #files{background:#f4f4f4}
:root[data-theme="classic"] .tgroup{background:none;border-color:#8e8e8e;border-radius:6px}
:root[data-theme="classic"] .tgroup button+button{border-left-color:#8e8e8e}
:root[data-theme="classic"] .tgroup button,
:root[data-theme="classic"] .btn,
:root[data-theme="classic"] .tabs{background:linear-gradient(#ffffff,#d9d9d9);border-color:#8e8e8e;color:#1a1a1a}
:root[data-theme="classic"] .tabs{border:1px solid #8e8e8e;border-radius:6px}
:root[data-theme="classic"] .tgroup button:hover:not(:disabled),
:root[data-theme="classic"] .btn:hover{background:linear-gradient(#f5f9ff,#cbdbf4)}
:root[data-theme="classic"] .btn.primary,
:root[data-theme="classic"] .tabs button.on,
:root[data-theme="classic"] .feed.sel,
:root[data-theme="classic"] .place.sel{background:linear-gradient(#86b0ec,#3a73cf);border-color:#2d5eae;color:#fff}
:root[data-theme="classic"] .feed.sel .txt small,
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
:root[data-theme="classic"] #epSearch{border-radius:14px}
:root[data-theme="classic"] .badge{background:#c9302c;color:#fff}
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
:root[data-theme="classic"] .ephead,
:root[data-theme="classic"] .fhd{
background:linear-gradient(#fbfbfb,#dcdcdc);color:#1a1a1a;text-transform:none;letter-spacing:0;font-size:11px;
}
:root[data-theme="classic"] .fhd{padding:3px 6px;border:1px solid #c2c2c2}
:root[data-theme="classic"] .ephead{border-bottom-color:#a3a3a3}
:root[data-theme="classic"] .ep{border-radius:0;margin:0}
:root[data-theme="classic"] #eps .ep:nth-child(even){background:#edf3fe}
:root[data-theme="classic"] .ep .t{color:#1c4fa8}
:root[data-theme="classic"] .ep.read .t{color:#333}
:root[data-theme="classic"] #eps .ep.sel{background:#3875d7;border-color:#3875d7}
:root[data-theme="classic"] .ep.sel .t,
:root[data-theme="classic"] .ep.sel .fd,
:root[data-theme="classic"] .ep.sel .date,
:root[data-theme="classic"] .ep.sel .line,
:root[data-theme="classic"] .ep.sel .st,
:root[data-theme="classic"] .ep.sel .fl,
:root[data-theme="classic"] .ep.sel .file,
:root[data-theme="classic"] .ep.sel .kind{color:#fff}
/* Green and red stay green and red on the blue, just lighter so they read. */
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}
/* Lists were white in the original; the pale blue-grey belongs to the source list alone. */
:root[data-theme="classic"] .childrow{background:#fff}
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
</style>
</head>
<body>
<header id="topbar">
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
<div class="tgroup">
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="minus" disabled></button>
</div>
<div class="tgroup item">
<button id="tbPlay" title="Play the selected item" aria-label="Play the selected item" data-icon="play" disabled></button>
</div>
<div class="tgroup item">
<button id="tbRead" title="Mark the selected item read or unread" aria-label="Mark read or unread" data-icon="check" disabled></button>
<button id="tbFlag" title="Keep the selected item, so it is never deleted" aria-label="Keep" data-icon="flag" disabled></button>
</div>
<div class="tgroup">
<button id="scanAll" title="Check every feed for new items" data-icon="scan"> Scan</button>
</div>
<span class="grow"></span>
<input type="search" id="epSearch" placeholder="Search items…">
<div class="tgroup" id="admintools">
<button id="prefs" title="Settings" aria-label="Settings" data-icon="settings"></button>
<button id="logs" title="Daemon and web log" aria-label="Log" data-icon="log"></button>
</div>
</header>
<div id="shell">
<aside id="sidebar">
<div class="brand">
<img class="logo" src="data:image/png;base64,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" alt="iPodderX" title="The original iPodderX icon, 2004"><h1>iPodderX</h1>
<button class="iconbtn" id="theme" title="Theme" aria-label="Theme" data-icon="theme"></button>
</div>
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
<div id="feedlist"></div>
<div class="sidefoot">
<div class="who"><span id="who"></span><button id="signout" title="Sign out">Sign out</button></div>
</div>
</aside>
<div id="main">
<div class="wrap" id="content"></div>
</div>
<div id="scrim" hidden></div>
</div>
<div id="status"><span id="count"></span></div>
<div id="player">
<div id="pnow">
<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]));
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
const ICON={
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
minus:fa('0 0 448 512','<path fill="currentColor" d="M0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32z"/>'), // solid/minus
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
check:fa('0 0 448 512','<path fill="currentColor" d="M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z"/>'), // solid/check
checks:fa('0 0 384 512','<path fill="currentColor" d="M249.9 66.8c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-106 145.7-37.5-37.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l128-176zm128 136c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-170 233.7-69.5-69.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l192-264z"/>'), // solid/check-double
flag:fa('0 0 448 512','<path fill="currentColor" d="M48 24C48 10.7 37.3 0 24 0S0 10.7 0 24L0 488c0 13.3 10.7 24 24 24s24-10.7 24-24l0-100 80.3-20.1c41.1-10.3 84.6-5.5 122.5 13.4 44.2 22.1 95.5 24.8 141.7 7.4l34.7-13c12.5-4.7 20.8-16.6 20.8-30l0-279.7c0-23-24.2-38-44.8-27.7l-9.6 4.8c-46.3 23.2-100.8 23.2-147.1 0-35.1-17.6-75.4-22-113.5-12.5L48 52 48 24zm0 77.5l96.6-24.2c27-6.7 55.5-3.6 80.4 8.8 54.9 27.4 118.7 29.7 175 6.8l0 241.8-24.4 9.1c-33.7 12.6-71.2 10.7-103.4-5.4-48.2-24.1-103.3-30.1-155.6-17.1l-68.6 17.2 0-237z"/>'), // regular/flag
flagOn:fa('0 0 448 512','<path fill="currentColor" d="M64 32C64 14.3 49.7 0 32 0S0 14.3 0 32L0 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-121.6 62.7-18.8c41.9-12.6 87.1-8.7 126.2 10.9 42.7 21.4 92.5 24 137.2 7.2l37.1-13.9c12.5-4.7 20.8-16.6 20.8-30l0-247.7c0-23-24.2-38-44.8-27.7l-11.8 5.9c-44.9 22.5-97.8 22.5-142.8 0-36.4-18.2-78.3-21.8-117.2-10.1L64 54.4 64 32z"/>'), // solid/flag
scan:fa('0 0 512 512','<path fill="currentColor" d="M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z"/>'), // solid/arrows-rotate
download:fa('0 0 448 512','<path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/>'), // solid/download
save:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>'), // solid/floppy-disk
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
log:fa('0 0 384 512','<path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0L213.5 0c17 0 33.3 6.7 45.3 18.7L365.3 125.3c12 12 18.7 28.3 18.7 45.3L384 448c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm208-5.5l0 93.5c0 13.3 10.7 24 24 24L325.5 176 208 58.5zM120 256c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0zm0 96c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0z"/>'), // solid/file-lines
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
theme:fa('0 0 512 512','<path fill="currentColor" d="M448 256c0-106-86-192-192-192l0 384c106 0 192-86 192-192zM0 256a256 256 0 1 1 512 0 256 256 0 1 1 -512 0z"/>'), // solid/circle-half-stroke
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
popular:fa('0 0 576 512','<path fill="currentColor" d="M309.5-18.9c-4.1-8-12.4-13.1-21.4-13.1s-17.3 5.1-21.4 13.1L193.1 125.3 33.2 150.7c-8.9 1.4-16.3 7.7-19.1 16.3s-.5 18 5.8 24.4l114.4 114.5-25.2 159.9c-1.4 8.9 2.3 17.9 9.6 23.2s16.9 6.1 25 2L288.1 417.6 432.4 491c8 4.1 17.7 3.3 25-2s11-14.2 9.6-23.2L441.7 305.9 556.1 191.4c6.4-6.4 8.6-15.8 5.8-24.4s-10.1-14.9-19.1-16.3L383 125.3 309.5-18.9z"/>'), // solid/star
all:fa('0 0 512 512','<path fill="currentColor" d="M232.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 149.8C5.4 145.8 0 137.3 0 128s5.4-17.9 13.9-21.8L232.5 5.2zM48.1 218.4l164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 277.8C5.4 273.8 0 265.3 0 256s5.4-17.9 13.9-21.8l34.1-15.8zM13.9 362.2l34.1-15.8 164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 405.8C5.4 401.8 0 393.3 0 384s5.4-17.9 13.9-21.8z"/>'), // solid/layer-group
unread:fa('0 0 512 512','<path fill="currentColor" d="M48 64c-26.5 0-48 21.5-48 48 0 15.1 7.1 29.3 19.2 38.4l208 156c17.1 12.8 40.5 12.8 57.6 0l208-156c12.1-9.1 19.2-23.3 19.2-38.4 0-26.5-21.5-48-48-48L48 64zM0 196L0 384c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-188-198.4 148.8c-34.1 25.6-81.1 25.6-115.2 0L0 196z"/>'), // solid/envelope: a closed letter, not a record button
audio:fa('0 0 448 512','<path fill="currentColor" d="M64 224c0-88.4 71.6-160 160-160s160 71.6 160 160l0 37.5c-10-3.5-20.8-5.5-32-5.5l-16 0c-26.5 0-48 21.5-48 48l0 128c0 26.5 21.5 48 48 48l16 0c53 0 96-43 96-96l0-160C448 100.3 347.7 0 224 0S0 100.3 0 224L0 384c0 53 43 96 96 96l16 0c26.5 0 48-21.5 48-48l0-128c0-26.5-21.5-48-48-48l-16 0c-11.2 0-22 1.9-32 5.5L64 224z"/>'), // solid/headphones
video:fa('0 0 576 512','<path fill="currentColor" d="M96 64c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L96 64zM464 336l73.5 58.8c4.2 3.4 9.4 5.2 14.8 5.2 13.1 0 23.7-10.6 23.7-23.7l0-240.6c0-13.1-10.6-23.7-23.7-23.7-5.4 0-10.6 1.8-14.8 5.2L464 176 464 336z"/>'), // solid/video
image:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32zm64 80a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM272 224c8.4 0 16.1 4.4 20.5 11.5l88 144c4.5 7.4 4.7 16.7 .5 24.3S368.7 416 360 416L88 416c-8.9 0-17.2-5-21.3-12.9s-3.5-17.5 1.6-24.8l56-80c4.5-6.4 11.8-10.2 19.7-10.2s15.2 3.8 19.7 10.2l26.4 37.8 61.4-100.5c4.4-7.1 12.1-11.5 20.5-11.5z"/>'), // solid/image
doc:fa('0 0 576 512','<path fill="currentColor" d="M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l80 0 0-112c0-35.3 28.7-64 64-64l176 0 0-165.5c0-17-6.7-33.3-18.7-45.3L290.7 18.7C278.7 6.7 262.5 0 245.5 0L96 0zM357.5 176L264 176c-13.3 0-24-10.7-24-24L240 58.5 357.5 176zM240 380c-11 0-20 9-20 20l0 128c0 11 9 20 20 20s20-9 20-20l0-28 12 0c33.1 0 60-26.9 60-60s-26.9-60-60-60l-32 0zm32 80l-12 0 0-40 12 0c11 0 20 9 20 20s-9 20-20 20zm96-80c-11 0-20 9-20 20l0 128c0 11 9 20 20 20l32 0c28.7 0 52-23.3 52-52l0-64c0-28.7-23.3-52-52-52l-32 0zm20 128l0-88 12 0c6.6 0 12 5.4 12 12l0 64c0 6.6-5.4 12-12 12l-12 0zm88-108l0 128c0 11 9 20 20 20s20-9 20-20l0-44 28 0c11 0 20-9 20-20s-9-20-20-20l-28 0 0-24 28 0c11 0 20-9 20-20s-9-20-20-20l-48 0c-11 0-20 9-20 20z"/>'), // solid/file-pdf
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
};
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
async function api(url,opts){
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
if(!r.ok) throw new Error(await r.text().catch(()=>r.status)||r.status);
return r.status===204?null:r.json().catch(()=>null);
}
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
// address it is undefined, so fall back to the old selection-based copy.
async function copyText(text,btn){
const flash=ok=>{
if(!btn) return;
const was=btn.textContent;
btn.textContent=ok?'Copied':'Failed';
setTimeout(()=>btn.textContent=was,1300);
};
try{
if(navigator.clipboard&&window.isSecureContext){
await navigator.clipboard.writeText(text);
}else{
const ta=document.createElement('textarea');
ta.value=text; ta.setAttribute('readonly','');
ta.style.cssText='position:fixed;top:-1000px;opacity:0';
document.body.appendChild(ta);
ta.select(); ta.setSelectionRange(0,ta.value.length);
const ok=document.execCommand('copy');
ta.remove();
if(!ok) throw new Error('copy rejected');
}
flash(true);
}catch(e){
flash(false);
toast('Could not copy automatically — select the URL and copy it manually',true);
}
}
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>`;
}
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
/* ---------------- state ---------------- */
const S = {
feeds:[], feed:null, entries:[], total:0, offset:0, limit:50,
filter:'all', q:'', sel:null, busy:new Set(), me:null,
};
const LIMIT = 50;
/* ---------------- feeds ---------------- */
async function loadFeeds(keepSel){
S.feeds = await api('/api/feeds');
api('/api/settings').then(g=>{globalEvery=g.every_mins;globalMax=g.max_new_per_check}).catch(()=>{});
renderFeeds();
if(!keepSel && !S.feed && S.feeds.length) selectFeed(S.feeds[0].id);
}
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
// server's alphabetical order still holds within each half.
const unreadFirst=(a,b)=>(b.unread>0)-(a.unread>0);
// The original's source list opened with these, above the feeds. They are places, not feeds:
// an id starting with ':' can never be a feed's, since feed ids are slugs.
const VIEWS={
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
blurb:'Every feed anyone on this server subscribes to, A to Z.'},
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
blurb:'The ten feeds with the most subscribers here.'},
':all':{title:'All Subscriptions',icon:ICON.all},
};
function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); const top=list.scrollTop; list.innerHTML='';
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
const places=document.createElement('div');
places.className='places';
for(const [id,v] of Object.entries(VIEWS)){
const el=document.createElement('div');
el.className='place'+(S.feed===id?' sel':'');
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
el.onclick=()=>{ selectFeed(id); nav(false); };
places.appendChild(el);
}
list.appendChild(places);
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return; }
// Feeds from a subscribed OPML sit under it, so the group reads as one thing.
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
const order=[];
for(const f of shown){
if(f.group && byId[f.group]) continue; // drawn under its parent instead
order.push([f,0]);
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
// opens them all, or matches inside a closed folder would be invisible.
if(expanded.has(f.id) || q)
for(const c of shown.filter(c=>c.group===f.id).sort(unreadFirst)) order.push([c,1]);
}
for(const [f,depth] of order){
const kids=shown.filter(c=>c.group===f.id).length;
// A subscription holds no entries itself, so its counts are the sum of what is inside --
// taken from every feed it holds, not just the ones a filter left showing.
const mine=S.feeds.filter(c=>c.group===f.id);
const sum=k=>mine.reduce((n,c)=>n+(c[k]||0),0);
const [unread,eps,saved]=mine.length
? [sum('unread'),sum('entries'),sum('downloaded')]
: [f.unread,f.entries,f.downloaded];
const el=document.createElement('div');
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
const open = kids && (expanded.has(f.id) || q);
el.innerHTML =
`<span class="chev${open?' open':''}"${kids?' title="Show or hide the feeds inside"':''}>${kids?'▶':''}</span>`+
artHTML(f.image,f.title||f.id)+
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
(mine.length?`${mine.length} feed${mine.length===1?'':'s'} · ${saved} saved`
:`${eps} items · ${saved} saved`)+
`</small></div>`+
(f.orphaned?'<span class="tag" title="No longer listed in the OPML, kept because it has downloads">gone</span>':'')+
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
el.onclick=()=>{ selectFeed(f.id); nav(false); };
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
list.appendChild(el);
}
list.scrollTop=top;
}
function selectFeed(id){
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
renderFeeds(); renderFeed(); loadEntries();
}
/* ---------------- feed page ---------------- */
function renderFeed(){
const box=$('#content');
box.classList.remove('plain');
const v=VIEWS[S.feed];
if(v&&v.url) return renderListed(v);
const f=v?null:S.feeds.find(x=>x.id===S.feed);
$('#tbRemove').disabled=!f;
syncTools(null);
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
const name=f?(f.title||f.id):v.title;
$('#epSearch').placeholder=`Search ${name}`;
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
if(kids.length){ box.classList.add('plain'); renderGroup(f,kids); return; }
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
box.innerHTML = (f ? `
<div class="fhead slim">
${artHTML(f.image,name)}
<div class="meta">
<h2>${esc(name)}</h2>
<div class="sub stat">${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)}
· every ${everyText(f.every_mins)}${f.next_check?` · next ${due(f.next_check)}`:''}${
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
</div>
<div class="acts">
<button class="btn ico primary" data-a="scan" title="Scan now" aria-label="Scan now">${ICON.scan}</button>
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger last" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.close}</button>
</div>
</div>` : `
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta">
<h2>${v.title}</h2>
<div class="sub">Every item from the ${S.feeds.length} feed${S.feeds.length===1?'':'s'} you
subscribe to, newest first · ${unreadAll} unread</div>
</div>
</div>`) + `
<div class="toolbar">
<div class="tabs">
${['all','unread','downloaded','flagged'].map(t=>
`<button data-f="${t}" class="${S.filter===t?'on':''}">${t[0].toUpperCase()+t.slice(1)}</button>`).join('')}
</div>
</div>`;
const pane=document.createElement('div');
pane.id='split';
if(f) pane.className='one';
pane.innerHTML='<div id="list"><div class="ephead"><span></span><span title="Kept">'+ICON.flag+'</span>'+
'<span>Item</span><span class="h-fd">Feed</span><span>File</span><span>Published</span><span></span></div>'+
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
box.appendChild(pane);
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '40%');
dragSplit(pane);
showDetail(null);
if(f) $$('#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()});
}
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
function renderGroup(f,kids){
const unread=kids.reduce((n,c)=>n+c.unread,0);
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
const gone=kids.filter(c=>c.orphaned).length;
$('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`;
$('#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">OPML subscription · ${kids.length} feed${kids.length===1?'':'s'}
· ${unread} unread · ${saved} downloaded · checked ${ago(f.last_checked)}
· every ${everyText(f.every_mins)}</div>
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
listed in this OPML but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
<div class="acts">
<button class="btn ico primary" data-a="scan" title="Re-read the OPML" aria-label="Re-read the OPML">${ICON.scan}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
<button class="btn ico danger last" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.close}</button>
</div>
</div>
</div>
<div class="toolbar">
<input type="search" class="grow" id="kidSearch" placeholder="Search these feeds…">
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
</div>
<div class="childlist" id="kidlist"></div>`;
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
const draw=()=>{
const q=($('#kidSearch').value||'').trim().toLowerCase();
const box=$('#kidlist'); box.innerHTML='';
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
for(const c of rows){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML = artHTML(c.image,c.title||c.id)+
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
`<small class="meta">${c.entries} items · ${c.downloaded} downloaded`+
(c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
(c.orphaned?'<span class="tag">gone</span>':'')+
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
el.onclick=()=>selectFeed(c.id);
box.appendChild(el);
}
};
$('#kidSearch').oninput=draw;
draw();
}
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); renderFeed(); loadEntries(); }
if(a==='rm') removeFeed(f);
if(a==='settings') settingsModal(f);
if(a==='dl') downloadLatestModal(f);
}
/* ---------------- items ---------------- */
async function loadEntries(append){
// Directory and Popular list feeds, not items.
if(!S.feed || VIEWS[S.feed]?.url) 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(S.feed===':all' ? `/api/entries?${p}`
: `/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 f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed];
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
$('#count').textContent=
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
const pane=$('#list'), top=pane?pane.scrollTop:0;
box.innerHTML='';
if(!S.entries.length){
box.innerHTML=`<p class="empty">${S.q?'Nothing matches that search.':'Nothing here yet — try Scan now.'}</p>`;
return;
}
for(const e of S.entries) box.appendChild(epEl(e));
if(pane) pane.scrollTop=top;
if(S.entries.length < S.total){
const b=document.createElement('button');
b.className='btn'; b.id='more'; b.textContent=`Load more (${S.entries.length} of ${S.total})`;
b.onclick=()=>{S.offset+=LIMIT;loadEntries(true)};
box.appendChild(b);
}
}
function epEl(e){
// An item may carry several files. The row summarises the one you would act on --
// the playable one, else anything already downloaded, else the first -- and says how
// many others there are; the pane below lists them all.
const enc=e.enclosures.find(isPlayable) || e.enclosures.find(x=>x.path) || e.enclosures[0];
const has=!!(enc&&enc.path);
const playable=isPlayable(enc);
const others=e.enclosures.length-1;
const el=document.createElement('div');
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'')+
(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=`
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?'▶':(e.read?'':'●')}</button>
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${
e.flagged?'Stop keeping':'Keep (never auto-delete)'}">${e.flagged?ICON.flagOn:ICON.flag}</button>
<div class="body">
<span class="t">${esc(e.title||'(untitled)')}</span>
<div class="line">${[
num&&`<span>${num}</span>`,
left&&`<span>${left}</span>`,
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
].filter(Boolean).join('<span class="dot"></span>')}</div>
</div>
<span class="fd">${esc(feedName(e.feed_id))}</span>
<div class="file">
${enc?kindIcon(enc):''}
${enc&&enc.length?`<span>${mb(enc.length)}</span>`:''}
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
</div>
<span class="date">${dateOf(e.published)}</span>
<div class="rowacts">
${playable?`<button class="iconbtn" data-a="play" title="Play" aria-label="Play">${ICON.play}</button>`:
(enc&&!has?`<button class="iconbtn" data-a="get" title="Download this ${
enc.state==='skipped'?kindOf(enc):'file'}">${ICON.download}</button>`:'')}
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
</div>`;
el.onclick=()=>selectEntry(e);
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
return el;
}
/// Whether the browser can play it. Having a file is not the same as being playable:
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
/// is just a broken player.
function isPlayable(x){
if(!x || !x.path) return false;
const m=(x.mime||'').toLowerCase();
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
if(m) return false;
// No declared type: fall back to the file's extension.
return /\.(mp3|m4a|m4b|aac|ogg|oga|opus|flac|wav|mp4|m4v|mov|webm|mkv)$/i
.test((x.path||x.url||'').split('?')[0]);
}
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
function kindOf(enc){
const m=(enc.mime||'').toLowerCase();
if(m.startsWith('image/')) return 'image';
if(m.startsWith('video/')) return 'video';
if(m.startsWith('audio/')) return 'audio';
if(m.includes('pdf')) return 'pdf';
if(m.includes('torrent')) return 'torrent';
const ext=(enc.url||'').split('?')[0].split('.').pop();
return (ext && ext.length<=5) ? ext.toLowerCase() : 'file';
}
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
/// either way keeps the column lined up; the words are in its tooltip.
function kindIcon(enc){
const k=kindOf(enc);
const i={audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent}[k]||ICON.file;
const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
: ['',k];
return `<span class="kind${cls}" title="${esc(label)}" aria-label="${esc(label)}">${i}</span>`;
}
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
/// Selecting an item shows it in the pane below, rather than expanding the row.
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
function swapRow(e){
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
if(row) row.replaceWith(epEl(e));
}
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
function markRead(e){
if(e.read) return;
e.read=true;
api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read:true})})
.then(()=>loadFeeds(true)).catch(err=>{ e.read=false; toast(err.message,true); });
}
function selectEntry(e){
S.sel=e.guid;
markRead(e);
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid));
swapRow(e);
showDetail(e);
const d=$('#detail'); if(d) d.scrollTop=0;
}
/// Drag the divider between the item list and the item text.
function dragSplit(pane){
const grab=$('#grab',pane);
if(!grab) return;
const move=ev=>{
const box=pane.getBoundingClientRect();
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
};
const stop=()=>{
document.removeEventListener('mousemove',move);
document.removeEventListener('mouseup',stop);
document.body.style.userSelect='';
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
};
grab.onmousedown=ev=>{
ev.preventDefault();
document.body.style.userSelect='none';
document.addEventListener('mousemove',move);
document.addEventListener('mouseup',stop);
};
}
/// Which item the toolbar's play, read and keep buttons act on.
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
// the first. Key rows by feed as well if that ever happens.
const cur=()=>S.entries.find(x=>x.guid===S.sel);
function syncTools(e){
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
if(e){
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
$('#tbFlag').title=e.flagged?'Stop keeping':'Keep, so it is never deleted';
}
}
/// The item's text in the pane below the list, and its files in the pane beside it.
function showDetail(e){
const box=$('#detail'), files=$('#files'); if(!box) return;
document.body.classList.toggle('reading',!!e);
syncTools(e);
if(!e){
box.innerHTML='<p class="empty">Pick an item to read it.</p>';
if(files) files.innerHTML='<p class="empty">No files</p>';
return;
}
const encs=e.enclosures.map(encBox).join('')||'<p class="empty">No files</p>';
// A phone has no room for the files pane, so the files follow the text there instead.
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
const f=S.feeds.find(x=>x.id===e.feed_id);
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
box.innerHTML=`
<button class="btn" id="dback">← Items</button>
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
<div class="dmeta">
${f?`<span>${esc(f.title||f.id)}</span><span class="dot"></span>`:''}
${num?`<span>${num}</span><span class="dot"></span>`:''}
<span>${dateOf(e.published)}</span>
${e.duration?`<span class="dot"></span><span>${clock(e.duration)}</span>`:''}
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}">${e.read?ICON.unread:ICON.check}</button>
<button class="btn ico" data-a="flag" title="${e.flagged?'Kept: never deleted. Stop keeping':'Keep, so it is never deleted'}"
aria-label="${e.flagged?'Stop keeping':'Keep'}">${e.flagged?ICON.flagOn:ICON.flag}</button>
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''}
</div>
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>
${narrow?encs:''}`;
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
// description was sanitized server-side with ammonia before it ever reached here
$('#dback').onclick=()=>showDetail(null);
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
for(const x of e.enclosures){
const a=$(`#audio-${x.id}`);
if(a) a.onplay=()=>play(e);
}
}
/// One enclosure: a player when the file is here, otherwise what it is and a way to get it.
function encBox(x){
const size=x.length?mb(x.length):'';
// One file serves everyone reading the feed, so deleting is not a private act.
const f=S.feeds.find(y=>y.id===x.feed_id);
const shared=f&&f.subscribers>1;
// Icons, with the words in the tooltip and for screen readers.
const delLabel=shared
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
: 'Delete file';
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
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}
${delBtn}
</div>`;
}
if(x.path){
return `<div class="encbox">
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
<span class="meta">${size}</span>
${saveBtn}
${delBtn}
</div>`;
}
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
// through here, which would make ipx a fetch-anything relay.
const viewable = !isPlayable(x) && x.state !== 'pending';
return `<div class="encbox">
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
</div>`;
}
async function epAction(a,e,el,encId){
// Swap the row in place and refresh the text below when it is the one being read.
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{
if(a==='play') play(e);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
if(a==='read'){ e.read=!e.read; await api(path+'/flags',{method:'POST',body:JSON.stringify({read:e.read})}); redraw(); 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||'item'));
}
if(a==='del'){
const f=S.feeds.find(x=>x.id===e.feed_id);
const shared=f&&f.subscribers>1;
if(!confirm(shared
? `Delete this file?\n\nThere is one copy, shared with ${f.subscribers-1} other `
+`${f.subscribers===2?'person':'people'} reading this feed. The item stays listed `
+`and will not be downloaded again automatically.`
: 'Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return;
try{
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
}catch(err){
// 409: somebody else has it starred or unplayed. Their reason, their words.
if(!/one copy of this file/.test(err.message)) throw err;
if(!confirm(err.message+'\n\nDelete it anyway?')) return;
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
}
toast('Deleted'); loadEntries(); loadFeeds(true);
}
}catch(err){ toast(err.message,true); }
}
/* ---------------- player ---------------- */
const audio=$('#audio');
const player={guid:null,feed:null,entry:null,saveAt:0,marked:false};
// Marked read when an item has actually been listened to -- at the end, or past 90%.
// NOT on play: doing that made the item vanish from the Unread list the instant
// you pressed play, which looks exactly like it went missing.
function markPlayed(){
if(!player.guid||player.marked) return;
player.marked=true;
const e=player.entry;
if(!e||e.read) return;
e.read=true;
api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read:true})}).then(()=>loadFeeds(true)).catch(()=>{});
}
function play(e){
const enc=e.enclosures.find(isPlayable);
if(!enc){
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : '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.feed_id),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);
player.marked=false;
}
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(); }
if(d && audio.currentTime/d >= 0.9) markPlayed();
});
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();markPlayed();$('#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();nav(false)}
});
/* ---------------- modals ---------------- */
function openModal(html,wide){
$('#modalCard').innerHTML=html;
$('#modalCard').classList.toggle('wide',!!wide);
$('#modal').classList.add('on');
}
function closeModal(){
$('#modal').classList.remove('on');
if(logTimer){ clearInterval(logTimer); logTimer=null; }
}
/* ---------------- log view ---------------- */
let logTimer=null, logSeq=0, logLines=[], logFilter='', logLevel='', logTab='all';
// Which sources belong to each tab. "daemon" is the control protocol itself: every
// command in and every event out, whatever sent it.
const LOG_TABS={
all: null,
daemon: t=>t==='ipx::io',
scan: t=>t==='ipx::scan',
web: t=>t==='ipx::http',
};
const LEVELS={ERROR:3,WARN:2,INFO:1,DEBUG:0,TRACE:0};
function logsModal(){
logSeq=0; logLines=[];
openModal(`<h3>Log</h3>
<div class="logbar">
<div class="tabs" id="logtabs">
${Object.keys(LOG_TABS).map(t=>
`<button data-t="${t}" class="${logTab===t?'on':''}">${
{all:'All',daemon:'Daemon I/O',scan:'Scans',web:'HTTP'}[t]}</button>`).join('')}
</div>
<select id="loglevel" style="width:auto">
<option value="">All levels</option>
<option value="INFO">Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
<input type="search" id="logq" class="grow" placeholder="Filter…">
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
<button class="btn" id="logcopy">Copy</button>
<button class="btn" onclick="closeModal()">Close</button>
</div>
<div id="logbox"><p class="empty">Loading…</p></div>
<span class="hint"><b>Daemon I/O</b> is the control protocol itself — every command in and
every event out. <b>Scans</b> is feed and download activity, <b>HTTP</b> is web requests.
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
what it captures.</span>`, true);
$$('#logtabs button').forEach(b=>b.onclick=()=>{
logTab=b.dataset.t;
$$('#logtabs button').forEach(x=>x.classList.toggle('on',x.dataset.t===logTab));
drawLog();
});
$('#loglevel').onchange=e=>{ logLevel=e.target.value; drawLog(); };
$('#logq').oninput=e=>{ logFilter=e.target.value.toLowerCase(); drawLog(); };
$('#logcopy').onclick=()=>copyText(visibleLog().map(l=>
`${new Date(l.ts*1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
pollLog();
logTimer=setInterval(pollLog,2000);
}
async function pollLog(){
try{
const r=await api(`/api/logs?after=${logSeq}&limit=500`);
if(r.lines.length){
logLines=logLines.concat(r.lines).slice(-2000);
logSeq=r.latest;
drawLog();
}else if(!logLines.length){ drawLog(); }
}catch(e){
const box=$('#logbox');
if(box) box.innerHTML=`<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`;
}
}
function visibleLog(){
const min=logLevel?LEVELS[logLevel]:-1;
const tab=LOG_TABS[logTab];
return logLines.filter(l=>
(!tab || tab(l.target)) &&
(LEVELS[l.level]??1)>=min &&
(!logFilter || (l.msg+' '+l.target).toLowerCase().includes(logFilter)));
}
function drawLog(){
const box=$('#logbox'); if(!box) return;
const follow=$('#logfollow')?.checked;
const rows=visibleLog();
box.innerHTML = rows.length ? rows.map(l=>{
const t=new Date(l.ts*1000).toLocaleTimeString();
if(logTab==='daemon'){
const out=l.msg.startsWith('<-');
return `<div class="l"><time>${t}</time>`+
`<span class="lv" style="color:${out?'var(--good)':'var(--accent)'}">${out?'out':'in'}</span>`+
`<span>${esc(l.msg.replace(/^[<-]+\s*/,''))}</span></div>`;
}
return `<div class="l"><time>${t}</time><span class="lv ${esc(l.level)}">${esc(l.level)}</span>`+
`<span class="tg">${esc(l.target.replace(/^ipx::?/,''))}</span><span>${esc(l.msg)}</span></div>`;
}).join('') : '<p class="empty">Nothing matches.</p>';
if(follow) box.scrollTop=box.scrollHeight;
}
$('#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 items matching a keyword are downloaded.</span></div>
<div class="field"><label>Popular on this server</label>
<div class="childlist" id="popular"><p class="hint">Loading…</p></div></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="nsave">Add feed</button></div>`);
$('#nurl').focus();
listFeeds('/api/popular');
$('#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; }
};
};
// 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.
async function listFeeds(url){
const box=$('#popular');
let rows=[];
try{ rows=await api(url)||[]; }catch{}
box.innerHTML=rows.length?'':'<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
for(const p of rows){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML=artHTML(p.image,p.title||p.id)+
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
(p.subscribed?'<span class="tag">Subscribed</span>':'<button class="btn" data-a="sub">Subscribe</button>');
// Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
$('[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); }
};
box.appendChild(el);
}
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…';
box.innerHTML=`
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta"><h2>${v.title}</h2>
<div class="sub">${v.blurb} Everyone counts, you included. Feeds inside an OPML
subscription, and private feeds, are never listed.</div></div>
</div>
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>`;
$('#count').textContent=v.title;
const n=await listFeeds(v.url);
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${n} feed${n===1?'':'s'}`;
}
// 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 globalEvery = 60, globalMax = 3;
const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']];
const UNIT_MINS = {m:1, h:60, d:1440, w:10080};
/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes".
function splitEvery(m){
if(!m) return {n:1, u:'h'};
for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u};
return {n:m, u:'m'};
}
function unitOptions(sel, firstLabel){
const head = firstLabel
? `<option value=""${sel===''?' selected':''}>${firstLabel}</option>` : '';
return head + UNITS.map(([v,l]) =>
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
}
function everyText(m){
if(!m) return '\u2014';
const {n,u} = splitEvery(m);
const name = {m:'min', h:'hour', d:'day', w:'week'}[u];
return n + ' ' + name + (u!=='m' && n!==1 ? 's' : '');
}
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';
}
async function prefsModal(){
const g = await api('/api/settings');
globalEvery = g.every_mins;
const gs = splitEvery(g.every_mins);
openModal(`<h3>Settings</h3>
<div class="field"><label>Check feeds every</label>
<div class="inline">
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
<select id="gunit">${unitOptions(gs.u)}</select>
</div>
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
<div class="field"><label>Max new downloads per scan, per feed</label>
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
<span class="hint">Applies to any feed that does not set its own — including every feed
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
catalogue the first time a feed is scanned.</span></div>
<div class="field"><label>Download these media types automatically</label>
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}"
placeholder="audio, video">
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
<span class="hint">Over this, the oldest played items are deleted first. Starred
items are never touched.</span></div>
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
<div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="field"><label>Subscriptions</label>
<div class="inline">
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml">Export OPML</a>
<button class="btn" id="gopml">Import OPML…</button>
</div>
<span class="hint">Export hands every subscription to another podcast app. Import adds the
feeds listed in an OPML you paste in.</span></div>
<div class="field"><label>Users</label>
<div class="inline"><button class="btn" id="gusers">Manage users…</button></div>
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
$('#gopml').onclick=opmlModal;
$('#gusers').onclick=usersModal;
$('#gsave').onclick=async()=>{
try{
await api('/api/settings',{method:'PATCH',body:JSON.stringify({
schedule:`every ${Math.max(1,Number($('#gnum').value)||1)}${$('#gunit').value}`,
max_new_per_check:Math.max(0,Number($('#gmax').value)||0),
media_types:$('#gtypes').value.split(',').map(t=>t.trim()).filter(Boolean),
max_total_gb:Number($('#gquota').value)||0,
max_age_days:Number($('#gage').value)||0})});
closeModal(); toast('Settings saved');
await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); }
}catch(e){ toast(e.message,true); }
};
}
// Admin only, and the server enforces that: this screen is just the way in.
async function usersModal(){
const users = await api('/api/users') || [];
openModal(`<h3>Users</h3>
${users.map(u=>`<div class="inline" data-id="${u.id}" style="margin-bottom:8px">
<b style="flex:1;overflow-wrap:anywhere">${esc(u.name)}</b>
${u.password?'':'<span class="tag" title="No password: signs in through the proxy">proxy</span>'}
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin?'checked':''}> Admin</label>
<button class="btn danger" data-a="rm">Remove</button></div>`).join('')}
<div class="field" style="margin-top:16px"><label>Add someone</label>
<div class="inline">
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
</div>
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
through the proxy. New people start with no feeds.</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Close</button>
<button class="btn primary" id="uadd">Add</button></div>`);
const change=async(u,opts)=>{
try{
await api(`/api/users/${u.id}`,opts);
// Demoting yourself takes this screen away; reload so the page stops offering it. Only
// on success: reloading after a refusal wiped the toast that said why.
if(u.name===S.me?.name){ location.reload(); return; }
}catch(e){ toast(e.message,true); }
usersModal(); // on a refusal, this puts the checkbox back where the server left it
};
$$('#modalCard [data-id]').forEach(row=>{
const u=users.find(x=>String(x.id)===row.dataset.id);
$('[data-a="admin"]',row).onchange=e=>
change(u,{method:'PATCH',body:JSON.stringify({admin:e.target.checked})});
$('[data-a="rm"]',row).onclick=()=>{
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
change(u,{method:'DELETE'});
};
});
$('#uadd').onclick=async()=>{
try{
await api('/api/users',{method:'POST',body:JSON.stringify({
name:$('#uname').value, password:$('#upass').value, admin:$('#uadmin').checked})});
toast('Added'); usersModal();
}catch(e){ toast(e.message,true); } // keep what was typed
};
}
function settingsModal(f){
const isGroup = S.feeds.some(c=>c.group===f.id);
openModal(`<h3>${esc(f.title||f.id)}</h3>
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is an OPML subscription. These
settings apply to it and are inherited by every feed inside it.</p>`:''}
${f.managed?`<p class="hint" style="margin:-6px 0 12px">This feed comes from 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.</p>`:''}
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed.
Everyone else keeps their own.</p>
<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 follows the global default (${globalMax}). The rest wait for
the next scan.</span></div>
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
<div class="field"><label>Feed URL</label>
<div class="inline">
<input type="text" id="surl" value="${esc(f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
<button type="button" class="btn" id="scopy">Copy</button>
</div>
<span class="hint">${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.`}</span></div>
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label>
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
<span class="hint">Where the files land. There is one copy however many people
subscribe, so this is the same for everyone.</span></div>`:''}
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
$('#ssave').onclick=async()=>{
const max=$('#smax').value;
try{
const patch={
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};
// The shared half is an admin's to change, and the API refuses it from anyone else.
if(S.me&&S.me.admin){
patch.url=$('#surl').value.trim();
patch.folder=$('#sfolder').value.trim()||null;
}
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
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 items</h3>
<div class="field"><label>How many of the newest undownloaded items?</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 ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.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} item${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. Anyone else
reading it keeps it, along with their own read state.
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();
};
}
function opmlModal(){
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: choose a file, or paste OPML</label>
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
<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()=>{
// A chosen file is read here and sent as text, so the server never stores it. Clearing
// the picker lets go of it on this side too, whether it was refused or imported.
const pick=$('#opmlFile'), file=pick.files[0];
const xml=file ? await file.text() : $('#opmlText').value;
const letGo=()=>{ pick.value=''; };
// A quick look before sending anything. The server parses it properly and has the last word.
if(!/<opml[\s>]/i.test(xml)){
letGo(); toast(`${file?file.name:'That'} is not an OPML file`,true); return;
}
try{
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
}catch(e){ letGo(); toast(e.message,true); }
};
}
function on(sel,ev,fn){
const el=$(sel);
if(!el){ console.error('ipx: no element',sel); return; }
if(typeof fn!=='function'){ console.error('ipx: handler for',sel,'is not a function'); return; }
el[ev]=fn;
}
$('#scanAll').onclick=async()=>{ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); };
on('#prefs','onclick',prefsModal);
on('#signout','onclick',async()=>{ await api('/api/logout',{method:'POST'}); location.href='/login'; });
api('/api/me').then(u=>{
S.me=u;
$('#who').textContent=u.name+(u.admin?' · admin':'');
// Scanning, quotas, accounts and the log are the operator's business. The server refuses
// them too; hiding the buttons just stops offering what would fail.
if(!u.admin) $('#admintools').hidden=true;
}).catch(()=>{});
on('#logs','onclick',logsModal);
$('#feedFilter').oninput=renderFeeds;
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
$('#scrim').onclick=()=>nav(false);
// Dark, light, and the 2004 Mac app. The button steps through them; the choice is remembered.
const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app'};
const THEME_ORDER=Object.keys(THEMES);
const nextTheme=t=>THEME_ORDER[(THEME_ORDER.indexOf(t)+1)%THEME_ORDER.length];
function setTheme(t){
if(!THEMES[t]) t='dark';
document.documentElement.dataset.theme=t;
$('#theme').title=`Theme: ${THEMES[t]}. Click for ${THEMES[nextTheme(t)]}`;
try{ localStorage.setItem('ipx.theme',t); }catch{}
}
$('#theme').onclick=()=>setTheme(nextTheme(document.documentElement.dataset.theme));
try{ setTheme(localStorage.getItem('ipx.theme')); }catch{ setTheme('dark'); }
/* ---------------- live events ---------------- */
let sse;
function connect(){
sse=new EventSource('/api/events');
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
const refreshFeeds=soon(()=>loadFeeds(true));
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
let fresh={};
const tellNew=soon(()=>{
const feeds=Object.keys(fresh), n=feeds.reduce((a,k)=>a+fresh[k],0);
if(n) toast(feeds.length===1 ? `${feeds[0]}: ${n} new` : `${n} new in ${feeds.length} feeds`);
fresh={};
},900);
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;
// Only the row actually downloading. Without the enclosure id this used to paint
// every pending bar at once, so adding a feed looked like it was fetching the lot.
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"] i`);
if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); }
$('#count') && ($('#count').textContent=`downloading ${ev.file}${pct.toFixed(0)}%`);
}
else if(ev.ev==='download_done'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live');
toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds();
}
else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live');
toast('Download failed: '+ev.msg,true); refreshEntries();
}
else if(ev.ev==='feed_done'){
if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); }
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
}
else if(ev.ev==='feed_error'){ toast(ev.feed+': '+ev.msg,true); refreshFeeds(); }
else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); }
};
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
}
connect();
loadFeeds();
</script>
</body>
</html>