Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Tk3nAVF6n4dtjQS17FRFr
2095 lines
122 KiB
HTML
2095 lines
122 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="color-scheme" content="dark light">
|
||
<title>iPodderX</title>
|
||
<link rel="icon" href="/icon.png">
|
||
<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);
|
||
}
|
||
: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:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */
|
||
--ink:#ffffff;
|
||
--good:#2f7d4f;
|
||
--warn:#b06f10;
|
||
--bad:#b3402f;
|
||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||
}
|
||
/* Auto: the same palette as Light, but only while the system is set to light -- the default
|
||
:root above is already dark, so nothing is needed for the dark half of Auto. Duplicated
|
||
rather than shared with [data-theme="light"], the same way Classic repeats its own values;
|
||
CSS custom properties have no way to say "these vars, but only under this media query". */
|
||
@media (prefers-color-scheme: light) {
|
||
:root[data-theme="auto"] {
|
||
--bg:#f2f4f7;
|
||
--panel:#ffffff;
|
||
--panel2:#e9edf3;
|
||
--raise:#dde3ec;
|
||
--line:#d6d6d6;
|
||
--fg:#1a1a1a;
|
||
--dim:#606060;
|
||
--faint:#767676;
|
||
--accent:#2d5391;
|
||
--accent2:#9a5f0a;
|
||
--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);
|
||
}
|
||
*{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;overflow:hidden;
|
||
/* iOS Safari's address bar collapses and expands without firing a resize, so 100vh is
|
||
measured against whichever state happened to be current -- sized too tall while the bar
|
||
is showing, which puts the topbar (the hamburger included) under Safari's own chrome,
|
||
where a tap never reaches the page. Only a hard refresh reset it, forcing 100vh to be
|
||
recomputed. 100dvh tracks the real visible viewport as the bar moves; the 100vh above is
|
||
the fallback for a browser that does not know dvh. */
|
||
height:100vh;height:100dvh;
|
||
}
|
||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||
a{color:var(--accent)}
|
||
/* Inset wherever a parent clips its overflow, or the toolbar and the feed list cut the ring off. */
|
||
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||
.tgroup button:focus-visible,.feed:focus-visible,.place:focus-visible,.chev:focus-visible{outline-offset:-2px}
|
||
@media (prefers-reduced-motion:reduce){
|
||
*,*::before,*::after{animation:none!important;transition:none!important}
|
||
}
|
||
::-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=password],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)}
|
||
/* The wider left gutter is the folder triangle's; everything in the list shifts with it, so the
|
||
feeds still line up with the places above. */
|
||
#feedlist{overflow-y:auto;padding:0 8px 12px 16px;flex:1;min-height:0}
|
||
.feed{
|
||
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
|
||
cursor:pointer;margin-bottom:1px;position:relative;
|
||
}
|
||
.feed:hover{background:var(--panel2)}
|
||
.feed.sel{background:var(--raise)}
|
||
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
|
||
.feed.child{margin-left:11px}
|
||
.feed.child .art{width:28px;height:28px;font-size:11px}
|
||
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
|
||
above it. The button is the row's full height and 24 px wide: a near miss used to open the
|
||
folder's page, and 16 px left the triangle cramped against the art. */
|
||
.chev{
|
||
position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;
|
||
border-radius:4px;color:var(--faint);
|
||
}
|
||
.chev:hover{color:var(--fg)}
|
||
.chev .i{width:12px;height:12px;transition:transform .12s}
|
||
.chev[aria-expanded="true"] .i{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-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.tag{
|
||
font-size:11px;font-weight:600;
|
||
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:600;overflow:hidden;
|
||
}
|
||
/* Initials, tinted by a hash of the name so two "TS" in a row can be told apart. Mixed into the
|
||
page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
|
||
.art.ini{background:color-mix(in srgb,var(--tint) 24%,var(--bg));color:var(--tint)}
|
||
/* A folder: its first four shows' art, as iTunes built a playlist's from its albums. */
|
||
.art.mosaic{grid-template:1fr 1fr/1fr 1fr;place-items:stretch}
|
||
.art.mosaic img{width:100%;height:100%;min-height:0;object-fit:cover;display:block}
|
||
.feed .art{width:34px;height:34px;font-size:13px}
|
||
.feed .txt{min-width:0;flex:1}
|
||
.feed .txt b{display:block;font-weight:500;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(--accent2);color:var(--ink);border-radius:20px;padding:1px 7px;
|
||
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
|
||
}
|
||
.badge.zero{background:var(--raise);color:var(--faint)}
|
||
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
||
.badge,.feed small,.childrow small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
||
|
||
/* ---------- 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,60%)) 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)}
|
||
/* Nothing selected, or an item with no files: the list takes the width. */
|
||
#split:has(>#files[hidden]){grid-template-columns:minmax(0,1fr) 0}
|
||
#files .encbox{margin-top:8px}
|
||
#files .empty{padding:24px 0}
|
||
.fhd{font-size:12px;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);
|
||
}
|
||
.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(--accent)}
|
||
.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}
|
||
/* Inline in a sentence, next to a plain-word error explanation. */
|
||
.btn.tiny{padding:2px 7px;font-size:11.5px;border-radius:6px;margin-left:2px}
|
||
/* 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)}
|
||
.subbed{display:inline-flex;padding:0 9px;color:var(--good)}
|
||
.subbed .i{width:18px;height:18px}
|
||
.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:500}
|
||
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
|
||
|
||
/* ---------- items ---------- */
|
||
/* A table, as the original's was: unread, kept, title, feed, file, size, published. */
|
||
.ephead,.ep{
|
||
display:grid;gap:8px;align-items:center;
|
||
grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,160px) 56px minmax(0,72px) 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:12px;color:var(--faint);
|
||
}
|
||
/* Each heading sorts by its column; the caret says which way. */
|
||
.ephead .hs{display:flex;align-items:center;gap:4px;min-width:0;padding:0;border:0;background:none;
|
||
font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit;text-align:left;cursor:pointer}
|
||
.ephead .hs:hover,.ephead .hs.on{color:var(--fg)}
|
||
.ephead .hs .arr{display:inline-flex}
|
||
.ephead .hs .arr .i{width:8px;height:8px}
|
||
.ephead .hs .arr.asc{transform:rotate(-90deg)}
|
||
.ephead .hs .arr.desc{transform:rotate(90deg)}
|
||
.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)}
|
||
/* Amber means new: the unread dot, the badges and a download under way. Blue is for the one
|
||
primary action and links, so a count no longer looks like a button. */
|
||
.ep .st,.ep .fl{width:22px;height:22px;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent2)}
|
||
.ep .fl{color:var(--faint);font-size:13px}
|
||
.ep .fl.on{color:var(--fg)}
|
||
/* The icon's EQ bars mark what is playing: standing still, and moving only while it plays. A
|
||
paused animation was tried first; the frames it held were a pixel apart and read as dots. */
|
||
.eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
|
||
.eq i{flex:1;height:100%;background:currentColor;border-radius:1px;transform-origin:bottom}
|
||
.eq i:nth-child(1){height:60%}
|
||
.eq i:nth-child(3){height:40%}
|
||
body.playing .eq i{animation:eq .9s ease-in-out infinite alternate}
|
||
body.playing .eq i:nth-child(1){animation-delay:-.3s}
|
||
body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
||
@keyframes eq{from{transform:scaleY(.35)}}
|
||
.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 .size,.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) 56px minmax(0,72px) 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}
|
||
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
|
||
.ep:hover .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(--accent2);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}
|
||
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
|
||
file needs to be seen, floated above the bar rather than laid into it, so an audio episode's
|
||
layout is unchanged. */
|
||
#audio{display:none}
|
||
body.has-video #audio{
|
||
display:block;position:fixed;z-index:45;right:16px;bottom:120px;
|
||
width:360px;max-width:calc(100vw - 32px);aspect-ratio:16/9;background:#000;
|
||
border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.5);
|
||
}
|
||
#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 .eq{color:var(--accent2);margin-right:6px}
|
||
#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}
|
||
/* 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:500;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:42;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}
|
||
#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 .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 .size,.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}
|
||
#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;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 .size,
|
||
: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>
|
||
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
|
||
A phone hides the item group, which it has no table for. -->
|
||
<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="circleMinus" disabled></button>
|
||
<button id="scanAll" title="Check every feed for new items" aria-label="Check every feed for new items" data-icon="scan"></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>
|
||
<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>
|
||
<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="/icon.png" 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" aria-label="Sign out" data-icon="signout"></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><span class="eq" aria-hidden="true"><i></i><i></i><i></i></span><span id="ptitle"></span></b><small id="pfeed"></small></div>
|
||
</div>
|
||
<div id="pmid">
|
||
<div id="pbtns">
|
||
<button class="iconbtn" id="pback" title="Back 15 seconds (←)" aria-label="Back 15 seconds" data-icon="back"></button>
|
||
<button class="iconbtn" id="pplay" title="Play or pause (space)" aria-label="Play or pause" data-icon="play"></button>
|
||
<button class="iconbtn" id="pfwd" title="Forward 30 seconds (→)" aria-label="Forward 30 seconds" data-icon="fwd"></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 the player" aria-label="Close the player" data-icon="close"></button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="modal"><div class="card" id="modalCard"></div></div>
|
||
<div id="toasts"></div>
|
||
<!-- One element for both: a <video> plays an audio-only file exactly like <audio> does (same
|
||
HTMLMediaElement API), and it is the only tag that can also show a picture. Hidden unless
|
||
the current file is video -- see body.has-video below. -->
|
||
<video id="audio" preload="metadata" playsinline></video>
|
||
|
||
<script>
|
||
'use strict';
|
||
const $ = (s,r=document)=>r.querySelector(s);
|
||
const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
|
||
const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
// 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
|
||
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
||
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
|
||
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
|
||
users:fa('0 0 640 512','<path fill="currentColor" d="M320 16a104 104 0 1 1 0 208 104 104 0 1 1 0-208zM96 88a72 72 0 1 1 0 144 72 72 0 1 1 0-144zM0 416c0-70.7 57.3-128 128-128 12.8 0 25.2 1.9 36.9 5.4-32.9 36.8-52.9 85.4-52.9 138.6l0 16c0 11.4 2.4 22.2 6.7 32L32 480c-17.7 0-32-14.3-32-32l0-32zm521.3 64c4.3-9.8 6.7-20.6 6.7-32l0-16c0-53.2-20-101.8-52.9-138.6 11.7-3.5 24.1-5.4 36.9-5.4 70.7 0 128 57.3 128 128l0 32c0 17.7-14.3 32-32 32l-86.7 0zM472 160a72 72 0 1 1 144 0 72 72 0 1 1 -144 0zM160 432c0-88.4 71.6-160 160-160s160 71.6 160 160l0 16c0 17.7-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32l0-16z"/>'), // solid/users
|
||
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
|
||
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
|
||
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
|
||
pause:fa('0 0 384 512','<path fill="currentColor" d="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/>'), // solid/pause
|
||
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
|
||
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
|
||
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check
|
||
};
|
||
// What is playing, as the icon's EQ bars; the stylesheet moves them.
|
||
const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
|
||
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
|
||
// dialog's confirm button carries the icon of what it does. Words go in the tooltip.
|
||
// 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;
|
||
// The button is an icon, so it is the markup that has to come back, not just its text.
|
||
const was=btn.innerHTML;
|
||
btn.textContent=ok?'Copied':'Failed';
|
||
setTimeout(()=>btn.innerHTML=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'}):'';
|
||
// A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB".
|
||
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
|
||
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
|
||
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
|
||
const plural=(n,word)=>`${n} ${word}${n===1?'':'s'}`;
|
||
// An initials tile takes one of these, by a hash of the name, so neighbours rarely match.
|
||
const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))'];
|
||
const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
|
||
function tileHTML(name,cls){
|
||
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
|
||
}
|
||
function artHTML(url,name,cls){
|
||
return url
|
||
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
|
||
: tileHTML(name,cls);
|
||
}
|
||
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
|
||
function folderArt(f,kids){
|
||
const art=kids.filter(c=>c.image).slice(0,4);
|
||
if(art.length<4) return artHTML(f.image,f.title||f.id);
|
||
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
|
||
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
|
||
`<img src="${esc(c.image)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</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:[],
|
||
// Which feed (or place) and which tab were open last time, so a refresh lands back where
|
||
// you were instead of jumping to the first feed alphabetically.
|
||
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
|
||
entries:[], total:0, offset:0,
|
||
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
|
||
q:'', sel:null, me:null,
|
||
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
|
||
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })()
|
||
||{col:'published',dir:'desc'},
|
||
};
|
||
const LIMIT = 50;
|
||
|
||
/* ---------------- feeds ---------------- */
|
||
async function loadFeeds(keepSel){
|
||
S.feeds = await api('/api/feeds');
|
||
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
|
||
renderFeeds();
|
||
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
|
||
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
|
||
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
||
if(!keepSel && S.feeds.length){
|
||
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
|
||
selectFeed(known ? S.feed : ':all');
|
||
}
|
||
}
|
||
// 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. The feeds inside an OPML are listed one by one, not the OPML.'},
|
||
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
|
||
blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'},
|
||
':all':{title:'All Subscriptions',icon:ICON.all},
|
||
};
|
||
function renderFeeds(){
|
||
const q=$('#feedFilter').value.trim().toLowerCase();
|
||
const list=$('#feedlist'); const top=list.scrollTop;
|
||
// Every row is replaced, so put the keyboard back on the row, or the triangle, it was on.
|
||
const a=document.activeElement, was=a&&a.closest&&a.closest('#feedlist [data-id]');
|
||
const back=was&&[was.dataset.id,a.classList.contains('chev')];
|
||
const done=()=>{
|
||
list.scrollTop=top;
|
||
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
|
||
if(row) (back[1]&&$('.chev',row)||row).focus();
|
||
};
|
||
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.tabIndex=0; el.dataset.id=id;
|
||
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 done(); }
|
||
|
||
// 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];
|
||
// A group's own row has no error of its own worth mentioning if the OPML itself
|
||
// reads fine; it is failing when any feed inside it is.
|
||
const failing=mine.length ? mine.find(c=>c.failing)?.failing : f.failing;
|
||
const el=document.createElement('div');
|
||
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
|
||
el.tabIndex=0; el.dataset.id=f.id;
|
||
const open = !!(kids && (expanded.has(f.id) || q));
|
||
el.innerHTML =
|
||
(kids?`<button class="chev" aria-expanded="${open}" title="Show or hide the feeds inside" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`:'')+
|
||
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+
|
||
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
|
||
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
|
||
`</small></div>`+
|
||
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||
(failing?`<span class="tag" style="color:var(--bad)" title="${esc(failing.reason)}">Error</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);
|
||
}
|
||
done();
|
||
}
|
||
function selectFeed(id){
|
||
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
|
||
try{ localStorage.setItem('ipx.feed',id); }catch{}
|
||
renderFeeds(); renderFeed(); loadEntries();
|
||
}
|
||
|
||
/// A failing feed's error, in plain words with something to do about it, once `failing` is
|
||
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
|
||
/// src/feed.rs). Anything else still shows the raw error, as before.
|
||
function failBannerHTML(f){
|
||
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
|
||
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
|
||
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
|
||
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
||
return '';
|
||
}
|
||
function wireFailBanner(box,f){
|
||
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
|
||
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
|
||
}
|
||
|
||
/* ---------------- 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" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
|
||
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
|
||
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
|
||
${failBannerHTML(f)}
|
||
${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="Check this feed now" aria-label="Check this feed 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" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</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 class="acts">
|
||
<button class="btn ico primary" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
|
||
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
|
||
</div>
|
||
</div>`) + `
|
||
<div class="toolbar">
|
||
<div class="tabs">
|
||
${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Kept']].map(([t,label])=>
|
||
`<button data-f="${t}" class="${S.filter===t?'on':''}">${label}</button>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
|
||
const pane=document.createElement('div');
|
||
pane.id='split';
|
||
if(f) pane.className='one';
|
||
pane.innerHTML='<div id="list">'+sortHead()+
|
||
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
||
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort));
|
||
box.appendChild(pane);
|
||
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
|
||
dragSplit(pane);
|
||
showDetail(null);
|
||
|
||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
|
||
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
|
||
S.filter=b.dataset.f; S.offset=0;
|
||
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
||
renderFeed(); loadEntries();
|
||
});
|
||
if(f) wireFailBanner(box,f);
|
||
}
|
||
|
||
/// The item table's headings, each a button that sorts by its column. The first click goes the
|
||
/// natural way round (A to Z; newest, largest and kept first) and the next one reverses it.
|
||
const COLS=[['kept','Kept',ICON.flag],['title','Title'],['feed','Feed'],['type','File'],['size','Size'],['published','Published']];
|
||
function sortHead(){
|
||
return '<div class="ephead"><span></span>'+COLS.map(([k,label,icon])=>{
|
||
const on=S.sort.col===k;
|
||
return `<button class="hs${on?' on':''}${k==='feed'?' h-fd':''}" data-sort="${k}"`+
|
||
` title="Sort by ${label.toLowerCase()}" aria-label="Sort by ${label.toLowerCase()}">${icon||label}`+
|
||
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
|
||
}).join('')+'<span></span></div>';
|
||
}
|
||
function sortBy(col){
|
||
const first=['published','size','kept'].includes(col)?'desc':'asc';
|
||
S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first};
|
||
try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{}
|
||
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.
|
||
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
|
||
const isPatreon=f=>/patreon\.com\//.test(f&&f.url||'');
|
||
|
||
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`;
|
||
// The same header as a feed's, buttons in the same places: it is a feed underneath.
|
||
$('#content').innerHTML = `
|
||
<div class="fhead slim">
|
||
${folderArt(f,kids)}
|
||
<div class="meta">
|
||
<h2>${esc(f.title||f.id)}</h2>
|
||
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
|
||
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
|
||
${failBannerHTML(f)}
|
||
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
|
||
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
||
</div>
|
||
<div class="acts">
|
||
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${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" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||
</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));
|
||
wireFailBanner($('#content'),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">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
|
||
(c.failing?` · <span style="color:var(--bad)" title="${esc(c.failing.reason)}">error</span>`
|
||
:c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
||
(c.orphaned?'<span class="tag">Gone</span>':'')+
|
||
(c.failing?`<span class="tag" style="color:var(--bad)" title="${esc(c.failing.reason)}">Error</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);
|
||
}
|
||
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
||
async function allAction(a){
|
||
if(a==='scanall') return scanAll();
|
||
if(a==='readall'){
|
||
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
|
||
if(!n){ toast('Nothing unread'); return; }
|
||
// One click across every feed is a lot to take back, so this one asks first.
|
||
if(!confirm(`Mark all ${n} unread item${n===1?'':'s'} read, in every feed you subscribe to?`)) return;
|
||
try{
|
||
const r=await api('/api/read-all',{method:'POST'});
|
||
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
|
||
}catch(e){ toast(e.message,true); }
|
||
}
|
||
}
|
||
|
||
/* ---------------- 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,sort:S.sort.col,dir:S.sort.dir});
|
||
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;
|
||
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
||
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
||
// tab -- would drop the item you have open the moment reading it took it off the filter.
|
||
// Keep it until you pick a different one; the next refresh after that no longer protects it.
|
||
if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){
|
||
const open=S.entries.find(e=>e.guid===S.sel);
|
||
if(open) entries=[open,...entries];
|
||
}
|
||
S.entries = 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':'');
|
||
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?EQ:(e.read?'':'●')}</button>
|
||
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${
|
||
e.flagged?'Stop keeping':'Keep, so it is never deleted'}">${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&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
|
||
</div>
|
||
<span class="size">${enc?mb(enc.length):''}</span>
|
||
<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;
|
||
// The same icons as the item's own buttons beside its title, so the two never disagree.
|
||
$('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check;
|
||
$('#tbFlag').innerHTML=e&&e.flagged?ICON.flagOn:ICON.flag;
|
||
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;
|
||
// A pane that can only say "No files" gives its width to the list instead.
|
||
if(files) files.hidden=!(e&&e.enclosures.length);
|
||
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 ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
|
||
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
|
||
<div class="dmeta">
|
||
${/* Joined, so a missing date or number leaves no stray dot behind. */
|
||
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
|
||
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></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));
|
||
}
|
||
|
||
/// One enclosure: a play button 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){
|
||
// One player, the bar at the bottom. This pane had an <audio> of its own, and playing it
|
||
// started the bar as well, so the same file played twice at once.
|
||
return `<div class="encbox">
|
||
${kindIcon(x)}
|
||
<span class="meta" style="flex:1">${size}</span>
|
||
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
|
||
${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, encId!=null ? enc : undefined);
|
||
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;
|
||
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(()=>{});
|
||
}
|
||
|
||
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
|
||
function play(e,enc=e.enclosures.find(isPlayable)){
|
||
if(!isPlayable(enc)){
|
||
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
||
return;
|
||
}
|
||
// The same file carries on where it was; another of the item's files starts from its top.
|
||
const resuming = player.guid===e.guid && player.enc===enc.id;
|
||
if(!resuming){
|
||
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id;
|
||
document.body.classList.toggle('has-video', kindOf(enc)==='video');
|
||
audio.src=`/media/${enc.id}`;
|
||
audio.currentTime=0;
|
||
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
||
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
|
||
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id));
|
||
$('#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').innerHTML=ICON.play});
|
||
// body.playing is what sets the EQ bars moving.
|
||
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
||
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
|
||
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');document.body.classList.remove('has-video');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=>{
|
||
// Escape leaves a dialog even from inside one of its boxes. It used to sit below the check
|
||
// that follows, so Add feed, which opens with the cursor in its URL box, ignored it.
|
||
if(ev.key==='Escape'){closeModal();nav(false);return}
|
||
// The rest are single keys that would otherwise eat what you type.
|
||
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()}
|
||
});
|
||
|
||
/* ---------------- 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 ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
|
||
<button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.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">
|
||
<span class="hint">A Patreon token on its own adds every show from that creator.</span></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>
|
||
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
|
||
<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 ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||
<button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
|
||
$('#nurl').focus();
|
||
listFeeds('/api/popular');
|
||
$('#nsave').onclick=async()=>{
|
||
const url=$('#nurl').value.trim(); if(!url) return;
|
||
$('#nsave').disabled=true; $('#nsave').title='Adding…';
|
||
try{
|
||
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
||
url, folder:$('#nfolder').value.trim()||null, allow_explicit:$('#nexp').checked,
|
||
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
|
||
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
|
||
await loadFeeds(true); selectFeed(r.id);
|
||
}catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
|
||
};
|
||
};
|
||
|
||
// What everyone here reads, you included, as a place to start. The rows carry an id, never a
|
||
// URL, so a key in someone's feed address never reaches this page.
|
||
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>`+
|
||
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
||
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</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…';
|
||
const listening=v===VIEWS[':popular'];
|
||
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. Private feeds are never listed.</div></div>
|
||
</div>
|
||
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>
|
||
${listening?`<div class="sub" style="margin:18px 0 8px;font-weight:600;color:var(--fg)">Currently Listening</div>
|
||
<div class="childlist" id="listening"><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'}`;
|
||
if(listening) renderListening();
|
||
}
|
||
|
||
/// Below Popular: episodes you started and have not finished, across every feed you
|
||
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
|
||
/// where you left off, not another way to browse.
|
||
async function renderListening(){
|
||
const box=$('#listening');
|
||
let rows=[];
|
||
try{ rows=(await api('/api/entries?filter=in_progress&limit=10')).entries||[]; }catch{}
|
||
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
||
for(const e of rows){
|
||
const el=document.createElement('div');
|
||
el.className='childrow';
|
||
const pct=e.duration?Math.min(100,Math.round(e.position/e.duration*100)):0;
|
||
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
||
`<div class="txt"><b>${esc(e.title||'(untitled)')}</b>`+
|
||
`<small class="meta">${esc(feedName(e.feed_id))} · ${clock(e.position)} of ${e.duration?clock(e.duration):'?'}</small>`+
|
||
`<div class="dlbar live"><i style="width:${pct}%"></i></div></div>`+
|
||
`<button class="btn ico primary" title="Resume" aria-label="Resume">${ICON.play}</button>`;
|
||
el.onclick=()=>play(e);
|
||
box.appendChild(el);
|
||
}
|
||
}
|
||
|
||
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
|
||
$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
|
||
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
|
||
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
|
||
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
||
let searchT;
|
||
$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
|
||
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; loadEntries(); },250); };
|
||
// Crossing the phone breakpoint moves the files between their pane and the text.
|
||
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
|
||
|
||
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
|
||
function toggleGroup(id){
|
||
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
|
||
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
|
||
renderFeeds();
|
||
}
|
||
let globalMax = 3;
|
||
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){
|
||
return 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';
|
||
}
|
||
|
||
/// Global settings. GET /api/settings is open to anyone signed in; only the PATCH, and the
|
||
/// Users screen behind it, are the operator's alone (the server refuses both from anyone
|
||
/// else). A non-admin gets the same modal minus those two parts, not no settings at all --
|
||
/// Export/Import are theirs regardless, and seeing the schedule and quota explains why a
|
||
/// feed is polled when it is.
|
||
async function prefsModal(){
|
||
const g = await api('/api/settings');
|
||
const gs = splitEvery(g.every_mins);
|
||
const admin = !!(S.me&&S.me.admin);
|
||
openModal(`<h3>Settings</h3>
|
||
<div class="field"><label>Theme</label>
|
||
<select id="stheme">${THEME_ORDER.map(t=>
|
||
`<option value="${t}"${document.documentElement.dataset.theme===t?' selected':''}>${THEMES[t]}</option>`).join('')}</select>
|
||
<span class="hint">Auto follows your system's light/dark setting. The same toggle is in
|
||
the header, one click at a time; this jumps straight to the one you want.</span></div>
|
||
<div class="field"><label>Check feeds every</label>
|
||
${admin?`<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>`
|
||
:`<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its
|
||
own. Only an admin changes this.</span>`}</div>
|
||
${admin?`<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. Kept
|
||
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">
|
||
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
|
||
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save} Export</a>
|
||
<button class="btn" id="gopml" title="Import OPML…" aria-label="Import OPML">${ICON.plus} Import…</button>
|
||
</div>
|
||
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
||
subscribes you to every feed in one.</span></div>
|
||
${admin?`<div class="field"><label>Users</label>
|
||
<div class="inline"><button class="btn ico" id="gusers" title="Manage users…" aria-label="Manage users">${ICON.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="${admin?'Cancel':'Close'}" aria-label="${admin?'Cancel':'Close'}">${ICON.close}</button>
|
||
${admin?`<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button>`:''}</div>`);
|
||
$('#stheme').onchange=e=>setTheme(e.target.value);
|
||
$('#gopml').onclick=opmlModal;
|
||
if(!admin) return;
|
||
$('#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">
|
||
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
|
||
<small style="display:block;color:var(--faint)">${u.created?`Added ${dateOf(u.created)}`:'Added before this was kept'} · ${
|
||
u.last_login?`signed in ${ago(u.last_login)}`:'never signed in'}</small></div>
|
||
${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 ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</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 ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
|
||
<button class="btn ico primary" id="uadd" title="Add" aria-label="Add">${ICON.plus}</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,newUrl){
|
||
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 ${isPatreon(f)?'a Patreon creator':'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
|
||
${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in
|
||
config.toml, and it stops following the subscription's settings.</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(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
|
||
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.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 ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</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="field"><label>Export: save your subscriptions as OPML</label>
|
||
<div class="inline">
|
||
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
|
||
</div></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 ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
|
||
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</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); }
|
||
};
|
||
}
|
||
|
||
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||
$('#scanAll').onclick=scanAll;
|
||
$('#prefs').onclick=prefsModal;
|
||
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
||
// the proxy still vouches for them. /api/me says where, when that is the case.
|
||
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
|
||
api('/api/me').then(u=>{
|
||
S.me=u;
|
||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||
// The log names every account and every failed sign-in; that alone is the operator's
|
||
// business, and the server refuses it from anyone else. Settings stays -- it opens the
|
||
// same modal for everyone, just without the admin-only fields (see prefsModal).
|
||
if(!u.admin) $('#logs').hidden=true;
|
||
}).catch(()=>{});
|
||
$('#logs').onclick=logsModal;
|
||
$('#feedFilter').oninput=renderFeeds;
|
||
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
||
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
|
||
$('#feedlist').onkeydown=ev=>{
|
||
const row=ev.target.closest('[data-id]'); if(!row) return;
|
||
const id=row.dataset.id;
|
||
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
||
else if(row.classList.contains('group')&&
|
||
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
||
else return;
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
};
|
||
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||
$('#scrim').onclick=()=>nav(false);
|
||
// Dark, light, the 2004 Mac app, and following the system. The header button steps through
|
||
// them and Settings offers the same four as a dropdown; either one sets ipx.theme and both
|
||
// read it, so they never disagree. Auto last in the cycle keeps the existing three clicks in
|
||
// "steps through dark, light and classic" reaching classic exactly where that test expects.
|
||
const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app',auto:'Auto (matches your system)'};
|
||
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)]}`;
|
||
const sel=$('#stheme'); if(sel) sel.value=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>
|