Almost no blog names a category the Directory can use, so a feed can carry one of its own in config.toml, set by an admin in the feed's settings and used when the feed names none. The feed's own iTunes category still wins. The field offers the categories the Directory already shows, so a blog about games joins Games rather than starting a second chip. Setting it on a feed from an OPML promotes it to config, as any other shared setting does. Closes #10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2250 lines
131 KiB
HTML
2250 lines
131 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}
|
||
/* [ hides the feed list. A phone slides it over the page instead, so this is for wider screens. */
|
||
@media (min-width:821px){
|
||
body.nosb #shell{grid-template-columns:minmax(0,1fr)}
|
||
body.nosb #sidebar{display:none}
|
||
}
|
||
#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}
|
||
/* Directory's filters: .tabs for what a feed is, as the item filters are, and chips for what it
|
||
is about. A picked chip is underlined in --accent2, as the download bar and the now-playing EQ
|
||
are; a .badge's fill already means unread in the sidebar. */
|
||
.dirbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:4px}
|
||
.dirbar .tabs{flex:none}
|
||
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;flex:1 1 0;min-width:0}
|
||
.chips button{flex:none;padding:4px 6px;font-size:13px;color:var(--dim);white-space:nowrap;border-bottom:2px solid transparent}
|
||
.chips button:hover{color:var(--fg)}
|
||
.chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)}
|
||
/* Square cover art, title underneath: podcast art is made to be known at a glance. The subscribe
|
||
button stays visible, since a button that only shows on hover cannot be reached on a phone. */
|
||
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 14px;margin-top:14px}
|
||
.tiles>.hint{grid-column:1/-1}
|
||
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start;cursor:pointer}
|
||
.tile .art{grid-column:1/-1;width:100%;height:auto;aspect-ratio:1;font-size:34px}
|
||
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
|
||
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
|
||
.tile:hover .txt b{color:var(--accent)}
|
||
.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,.tile 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}
|
||
/* The keys ? lists. */
|
||
.keys{border-collapse:collapse;width:100%;font-size:13px}
|
||
.keys th{text-align:left;font-weight:600;padding:12px 0 4px;color:var(--fg)}
|
||
.keys td{padding:3px 0;color:var(--dim)}
|
||
.keys td:first-child{width:10em;white-space:nowrap}
|
||
kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:0 5px}
|
||
/* 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}
|
||
/* The chips get a line of their own and scroll sideways rather than wrap, so they never push
|
||
the grid down. */
|
||
.chips{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto}
|
||
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
|
||
|
||
/* 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="Pin the selected item, so it is never deleted" aria-label="Pin" data-icon="pin" 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
|
||
// Pinned is the solid thumbtack; not pinned, the same shape outlined, as the flag had its regular
|
||
// and solid pair (Font Awesome's free set has no regular thumbtack). Both share a viewBox padded
|
||
// for the outline's stroke, so the two draw the same size.
|
||
pin:fa('-18 -18 420 548','<path fill="none" stroke="currentColor" stroke-width="36" stroke-linejoin="round" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack, outlined
|
||
pinOn:fa('-18 -18 420 548','<path fill="currentColor" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack
|
||
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.'},
|
||
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
|
||
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
||
':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','Pinned']].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','Pinned',ICON.pin],['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?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</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.pinOn:ICON.pin;
|
||
if(e){
|
||
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
||
$('#tbFlag').title=e.flagged?'Unpin':'Pin, 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?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
||
aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</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()}
|
||
else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev);
|
||
});
|
||
|
||
// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a
|
||
// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's
|
||
// own shortcuts still work, or while a dialog is open.
|
||
const GO={a:':all',d:':directory',p:':popular',l:':listening'};
|
||
let gAt=0;
|
||
function stepEntry(by){
|
||
if(VIEWS[S.feed]?.url||!S.entries.length) return;
|
||
const i=S.entries.findIndex(x=>x.guid===S.sel);
|
||
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
|
||
selectEntry(e);
|
||
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
|
||
}
|
||
function stepFeed(by){
|
||
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
|
||
if(!id) return;
|
||
selectFeed(id);
|
||
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
|
||
}
|
||
const KEYS={
|
||
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
|
||
J:()=>stepFeed(1), K:()=>stepFeed(-1),
|
||
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
|
||
// they are disabled.
|
||
o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(),
|
||
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); },
|
||
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
|
||
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
|
||
'[':()=>matchMedia('(max-width:820px)').matches
|
||
? nav(!$('#sidebar').classList.contains('open')) : document.body.classList.toggle('nosb'),
|
||
'?':()=>keysModal(),
|
||
g:()=>{ gAt=Date.now(); },
|
||
};
|
||
function typed(ev){
|
||
// The second key of a g pair counts only if it follows within a second and a half.
|
||
const pair=Date.now()-gAt<1500; gAt=0;
|
||
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
|
||
if(!fn) return;
|
||
ev.preventDefault(); fn();
|
||
}
|
||
/// What ? shows: every key, grouped as Feedly's own list is.
|
||
function keysModal(){
|
||
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c);
|
||
const rows=[
|
||
['Go to'],
|
||
[g('a'),'All Subscriptions'],[g('d'),'Directory'],[g('p'),'Popular'],
|
||
[g('l'),'Currently Listening'],[g('s'),'Settings'],
|
||
[k('Shift')+' '+k('J'),'Next feed'],[k('Shift')+' '+k('K'),'Previous feed'],
|
||
[k('/'),'Search items'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'],
|
||
['Items'],
|
||
[k('j')+' or '+k('n'),'Next item'],[k('k')+' or '+k('p'),'Previous item'],
|
||
[k('Shift')+' '+k('A'),'Mark all read'],
|
||
['The selected item'],
|
||
[k('o'),'Play it'],[k('m'),'Mark it read or unread'],[k('s'),'Pin it, or unpin it'],
|
||
[k('v'),'Open the original in a new tab'],
|
||
['The player'],
|
||
[k('Space'),'Play or pause'],[k('←')+' '+k('→'),'Back 15 seconds, forward 30'],
|
||
['Anywhere'],
|
||
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
|
||
];
|
||
openModal(`<h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
|
||
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>
|
||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
|
||
}
|
||
|
||
/* ---------------- 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="npopular"><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',$('#npopular'));
|
||
$('#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. The caller hands over the
|
||
// box: looked up by id, the Add a feed dialog's list landed in the Directory pane behind it.
|
||
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
|
||
async function listFeeds(url,box){
|
||
let rows=[];
|
||
try{ rows=await api(url)||[]; }catch{}
|
||
box.innerHTML=rows.length?'':NONE_LISTED;
|
||
for(const p of rows) box.appendChild(listedFeed(p,'childrow'));
|
||
return rows.length;
|
||
}
|
||
|
||
/// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The
|
||
/// parts are the same either way; the class lays them out.
|
||
function listedFeed(p,cls){
|
||
const el=document.createElement('div');
|
||
el.className=cls;
|
||
el.innerHTML=artHTML(p.image,p.title||p.id)+
|
||
`<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); }; return el; }
|
||
$('[data-a="sub"]',el).onclick=async()=>{
|
||
try{
|
||
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
|
||
await loadFeeds(true); selectFeed(p.id);
|
||
}catch(e){ toast(e.message,true); }
|
||
};
|
||
return el;
|
||
}
|
||
|
||
// Directory's filters. Kept out here because a finished scan redraws the pane, which would
|
||
// otherwise clear them.
|
||
let dirKind='All', dirCat=null;
|
||
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
||
/// Directory: every listed feed as its cover art, under two filters that combine: what a feed is
|
||
/// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes
|
||
/// category, as chips). Both filter in place, without asking the server again.
|
||
async function renderDirectory(url,box){
|
||
let rows=[];
|
||
try{ rows=await api(url)||[]; }catch{}
|
||
if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; }
|
||
const bar=$('#dirbar');
|
||
// Only a filter when the server has both kinds.
|
||
const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs);
|
||
const btn=(k,v,on)=>`<button type="button" data-${k}="${esc(v)}" class="${on?'on':''}" aria-pressed="${on}">${esc(v)}</button>`;
|
||
const draw=()=>{
|
||
if(!both) dirKind='All';
|
||
const ofKind=rows.filter(KINDS[dirKind]);
|
||
// No empty chips: only the categories among the feeds the kind lets through.
|
||
const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
|
||
if(!cats.includes(dirCat)) dirCat=null;
|
||
bar.innerHTML=
|
||
(both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
|
||
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'');
|
||
// A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
|
||
// the button just pressed.
|
||
for(const b of $$('button',bar)) b.onclick=()=>{
|
||
const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k];
|
||
if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v;
|
||
draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus();
|
||
};
|
||
box.innerHTML='';
|
||
for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile'));
|
||
};
|
||
draw();
|
||
return rows.length;
|
||
}
|
||
|
||
/// Directory and Popular open in the main pane, as the original's Directory did.
|
||
async function renderListed(v){
|
||
const box=$('#content');
|
||
box.classList.add('plain');
|
||
$('#tbRemove').disabled=true;
|
||
syncTools(null);
|
||
$('#epSearch').placeholder='Search items…';
|
||
const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory'];
|
||
box.innerHTML=`
|
||
<div class="fhead slim">
|
||
<div class="art">${v.icon}</div>
|
||
<div class="meta"><h2>${v.title}</h2>
|
||
<div class="sub">${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}</div></div>
|
||
</div>
|
||
${grid?'<div class="dirbar" id="dirbar"></div>':''}
|
||
<div class="${grid?'tiles':'childlist'}" id="${listening?'listening':'popular'}"><p class="hint">Loading…</p></div>`;
|
||
$('#count').textContent=v.title;
|
||
const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box));
|
||
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`;
|
||
}
|
||
|
||
/// Currently Listening: episodes you started and have not finished, across every feed you
|
||
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
|
||
/// where you left off, not another way to browse.
|
||
async function renderListening(url,box){
|
||
let rows=[];
|
||
try{ rows=(await api(url)).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);
|
||
}
|
||
return rows.length;
|
||
}
|
||
|
||
// 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. Pinned
|
||
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>`:''}
|
||
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
|
||
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
|
||
<datalist id="scats"></datalist>
|
||
<span class="hint">${f.feed_category
|
||
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.`
|
||
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</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'));
|
||
// Offer the categories the Directory already shows, so a blog about games joins Games rather
|
||
// than starting a second chip beside it.
|
||
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
|
||
.map(p=>p.category).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
|
||
$('#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;
|
||
patch.category=$('#scat').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>
|