|
|
|
|
@@ -37,7 +37,7 @@
|
|
|
|
|
--dim:#606060; /* #606060 */
|
|
|
|
|
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
|
|
|
|
|
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
|
|
|
|
--accent2:#b06f10;
|
|
|
|
|
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */
|
|
|
|
|
--ink:#ffffff;
|
|
|
|
|
--good:#2f7d4f;
|
|
|
|
|
--warn:#b06f10;
|
|
|
|
|
@@ -75,6 +75,12 @@ body{
|
|
|
|
|
}
|
|
|
|
|
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}
|
|
|
|
|
@@ -133,18 +139,22 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|
|
|
|
#feedlist{overflow-y:auto;padding:0 8px 12px;flex:1;min-height:0}
|
|
|
|
|
.feed{
|
|
|
|
|
display:flex;gap:10px;align-items:center;padding:7px 8px;border-radius:9px;
|
|
|
|
|
cursor:pointer;margin-bottom:1px;
|
|
|
|
|
cursor:pointer;margin-bottom:1px;position:relative;
|
|
|
|
|
}
|
|
|
|
|
.feed:hover{background:var(--panel2)}
|
|
|
|
|
.feed.sel{background:var(--raise)}
|
|
|
|
|
.feed.child{margin-left:16px}
|
|
|
|
|
/* A show sits under its folder's title, a size down, so an open folder reads as one. */
|
|
|
|
|
.feed.child{margin-left:44px}
|
|
|
|
|
.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: a near miss used to open the folder's page. */
|
|
|
|
|
.chev{
|
|
|
|
|
flex:none;width:18px;height:18px;display:grid;place-items:center;border-radius:4px;
|
|
|
|
|
color:var(--faint);font-size:11px;transition:transform .12s;
|
|
|
|
|
position:absolute;left:-8px;top:0;bottom:0;width:16px;display:grid;place-items:center;
|
|
|
|
|
border-radius:4px;color:var(--faint);
|
|
|
|
|
}
|
|
|
|
|
.chev:empty{pointer-events:none}
|
|
|
|
|
.chev:not(:empty):hover{background:var(--raise);color:var(--fg)}
|
|
|
|
|
.chev.open{transform:rotate(90deg)}
|
|
|
|
|
.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);
|
|
|
|
|
@@ -153,24 +163,32 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|
|
|
|
.childrow:hover{border-color:var(--accent)}
|
|
|
|
|
.childrow .art{width:32px;height:32px;font-size:12px}
|
|
|
|
|
.childrow .txt{flex:1;min-width:0}
|
|
|
|
|
.childrow .txt b{display:block;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
|
|
|
.childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
|
|
|
.tag{
|
|
|
|
|
font-size:10px;text-transform:uppercase;letter-spacing:.04em;font-weight:700;
|
|
|
|
|
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:700;overflow:hidden;
|
|
|
|
|
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:600;font-size:13.5px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
|
|
|
.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(--accent);color:var(--ink);border-radius:20px;padding:1px 7px;
|
|
|
|
|
font-size:11px;font-weight:700;flex:none;min-width:26px;text-align:center;
|
|
|
|
|
background:var(--accent2);color:var(--ink);border-radius:20px;padding:1px 7px;
|
|
|
|
|
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
|
|
|
|
|
}
|
|
|
|
|
.badge.zero{background:var(--raise);color:var(--faint)}
|
|
|
|
|
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
|
|
|
|
.badge,.feed small,.childrow small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
|
|
|
|
|
|
|
|
|
|
/* ---------- main ---------- */
|
|
|
|
|
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
|
|
|
|
|
@@ -184,15 +202,17 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|
|
|
|
#split{
|
|
|
|
|
display:grid;flex:1;min-height:0;
|
|
|
|
|
grid-template-columns:minmax(0,1fr) 270px;
|
|
|
|
|
grid-template-rows:minmax(90px,var(--listh,40%)) 7px 1fr;
|
|
|
|
|
grid-template-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:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint)}
|
|
|
|
|
.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)}
|
|
|
|
|
@@ -219,10 +239,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|
|
|
|
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
|
|
|
|
|
.acts .btn.primary i{opacity:.9}
|
|
|
|
|
.acts .btn:hover{background:var(--raise)}
|
|
|
|
|
.acts .btn.primary:hover{background:var(--accent2);border-color:var(--accent2)}
|
|
|
|
|
/* Leaving a feed is not one of the everyday actions: keep it off on its own. */
|
|
|
|
|
.acts .btn.last{margin-left:auto;border-color:transparent;background:none;color:var(--faint)}
|
|
|
|
|
.acts .btn.last:hover{background:none;border-color:var(--bad);color:var(--bad)}
|
|
|
|
|
.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;
|
|
|
|
|
@@ -250,7 +267,7 @@ a.btn{text-decoration:none;color:inherit}
|
|
|
|
|
}
|
|
|
|
|
.tabs{display:flex;gap:2px;background:var(--panel2);border-radius:9px;padding:3px}
|
|
|
|
|
.tabs button{padding:5px 12px;border-radius:7px;font-size:13px;color:var(--dim)}
|
|
|
|
|
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:600}
|
|
|
|
|
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:500}
|
|
|
|
|
.toolbar .grow{flex:1;min-width:150px;max-width:320px}
|
|
|
|
|
|
|
|
|
|
/* ---------- items ---------- */
|
|
|
|
|
@@ -262,7 +279,7 @@ a.btn{text-decoration:none;color:inherit}
|
|
|
|
|
.ephead{
|
|
|
|
|
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 10px 5px;
|
|
|
|
|
border-bottom:1px solid var(--line);margin-bottom:3px;
|
|
|
|
|
font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);
|
|
|
|
|
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;
|
|
|
|
|
@@ -276,10 +293,21 @@ a.btn{text-decoration:none;color:inherit}
|
|
|
|
|
.ep>*{min-width:0}
|
|
|
|
|
.ep.sel{background:var(--raise);border-color:var(--line)}
|
|
|
|
|
.ep:hover{background:var(--panel)}
|
|
|
|
|
.ep.playing{border-color:var(--accent)}
|
|
|
|
|
.ep .st,.ep .fl{width:22px;height:22px;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent)}
|
|
|
|
|
/* 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(--accent2)}
|
|
|
|
|
.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}
|
|
|
|
|
@@ -293,19 +321,11 @@ a.btn{text-decoration:none;color:inherit}
|
|
|
|
|
#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}
|
|
|
|
|
.chip{
|
|
|
|
|
font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;font-weight:700;
|
|
|
|
|
padding:1px 6px;border-radius:5px;background:var(--raise);color:var(--dim);
|
|
|
|
|
}
|
|
|
|
|
.chip.done{background:color-mix(in srgb,var(--good) 16%,transparent);color:var(--good)}
|
|
|
|
|
.chip.error{background:color-mix(in srgb,var(--bad) 16%,transparent);color:var(--bad)}
|
|
|
|
|
.chip.pending{background:color-mix(in srgb,var(--warn) 16%,transparent);color:var(--warn)}
|
|
|
|
|
.chip.new{background:var(--accent);color:var(--ink)}
|
|
|
|
|
.ep .rowacts{display:flex;gap:2px;align-items:flex-start;opacity:0;transition:opacity .12s}
|
|
|
|
|
.ep:hover .rowacts,.ep.open .rowacts{opacity:1}
|
|
|
|
|
.dlbar{height:3px;background:transparent;border-radius:2px;overflow:hidden;margin-top:7px}
|
|
|
|
|
.dlbar.live{background:var(--raise)}
|
|
|
|
|
.dlbar i{display:block;height:100%;width:0;background:var(--accent);transition:width .25s}
|
|
|
|
|
.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}
|
|
|
|
|
|
|
|
|
|
@@ -320,6 +340,7 @@ a.btn{text-decoration:none;color:inherit}
|
|
|
|
|
#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}
|
|
|
|
|
@@ -400,14 +421,13 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|
|
|
|
.fhead.slim .sub{margin-bottom:0;font-size:12.5px}
|
|
|
|
|
.fhead.slim .acts{margin-top:0;flex:none}
|
|
|
|
|
.fhead.slim .acts .btn{padding:5px 11px;font-size:12.5px}
|
|
|
|
|
.fhead.slim .acts .btn.last{margin-left:0}
|
|
|
|
|
/* Places above the feeds: not subscriptions, but where to look. */
|
|
|
|
|
.places{border-bottom:1px solid var(--line);margin:0 0 6px;padding-bottom:6px}
|
|
|
|
|
.place{display:flex;gap:10px;align-items:center;padding:6px 8px;border-radius:9px;cursor:pointer}
|
|
|
|
|
.place:hover{background:var(--panel2)}
|
|
|
|
|
.place.sel{background:var(--raise)}
|
|
|
|
|
.place .ico{width:18px;text-align:center;color:var(--accent);flex:none}
|
|
|
|
|
.place b{flex:1;font-weight:600;font-size:13.5px}
|
|
|
|
|
.place b{flex:1;font-weight:500;font-size:13.5px}
|
|
|
|
|
|
|
|
|
|
@media (max-width:820px){
|
|
|
|
|
#shell{grid-template-columns:1fr}
|
|
|
|
|
@@ -495,7 +515,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|
|
|
|
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
|
|
|
|
|
:root[data-theme="classic"] .ephead,
|
|
|
|
|
:root[data-theme="classic"] .fhd{
|
|
|
|
|
background:linear-gradient(#fbfbfb,#dcdcdc);color:#1a1a1a;text-transform:none;letter-spacing:0;font-size:11px;
|
|
|
|
|
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}
|
|
|
|
|
@@ -565,7 +585,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|
|
|
|
<div id="player">
|
|
|
|
|
<div id="pnow">
|
|
|
|
|
<div id="partwrap"></div>
|
|
|
|
|
<div class="txt"><b id="ptitle"></b><small id="pfeed"></small></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">
|
|
|
|
|
@@ -642,6 +662,8 @@ const ICON={
|
|
|
|
|
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.
|
|
|
|
|
@@ -707,10 +729,25 @@ const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'shor
|
|
|
|
|
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.replaceWith(Object.assign(document.createElement('div'),{className:'art ${cls||''}',textContent:'${esc(initials(name))}'}))">`
|
|
|
|
|
: `<div class="art ${cls||''}">${esc(initials(name))}</div>`;
|
|
|
|
|
? `<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.
|
|
|
|
|
@@ -747,13 +784,23 @@ const VIEWS={
|
|
|
|
|
};
|
|
|
|
|
function renderFeeds(){
|
|
|
|
|
const q=$('#feedFilter').value.trim().toLowerCase();
|
|
|
|
|
const list=$('#feedlist'); const top=list.scrollTop; list.innerHTML='';
|
|
|
|
|
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); };
|
|
|
|
|
@@ -761,7 +808,7 @@ function renderFeeds(){
|
|
|
|
|
}
|
|
|
|
|
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; }
|
|
|
|
|
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]));
|
|
|
|
|
@@ -785,21 +832,21 @@ function renderFeeds(){
|
|
|
|
|
: [f.unread,f.entries,f.downloaded];
|
|
|
|
|
const el=document.createElement('div');
|
|
|
|
|
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
|
|
|
|
|
const open = kids && (expanded.has(f.id) || q);
|
|
|
|
|
el.tabIndex=0; el.dataset.id=f.id;
|
|
|
|
|
const open = !!(kids && (expanded.has(f.id) || q));
|
|
|
|
|
el.innerHTML =
|
|
|
|
|
`<span class="chev${open?' open':''}"${kids?' title="Show or hide the feeds inside"':''}>${kids?ICON.caret:''}</span>`+
|
|
|
|
|
artHTML(f.image,f.title||f.id)+
|
|
|
|
|
(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?`${mine.length} feed${mine.length===1?'':'s'} · ${saved} saved`
|
|
|
|
|
:`${eps} items · ${saved} saved`)+
|
|
|
|
|
`${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>':'')+
|
|
|
|
|
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
|
|
|
|
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
|
|
|
|
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
|
|
|
|
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
|
|
|
|
list.appendChild(el);
|
|
|
|
|
}
|
|
|
|
|
list.scrollTop=top;
|
|
|
|
|
done();
|
|
|
|
|
}
|
|
|
|
|
function selectFeed(id){
|
|
|
|
|
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
|
|
|
|
|
@@ -826,8 +873,8 @@ function renderFeed(){
|
|
|
|
|
${artHTML(f.image,name)}
|
|
|
|
|
<div class="meta">
|
|
|
|
|
<h2>${esc(name)}</h2>
|
|
|
|
|
<div class="sub stat">${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)}
|
|
|
|
|
· every ${everyText(f.every_mins)}${f.next_check?` · next ${due(f.next_check)}`:''}${
|
|
|
|
|
<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>
|
|
|
|
|
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
|
|
|
|
|
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
|
|
|
|
|
@@ -839,7 +886,7 @@ function renderFeed(){
|
|
|
|
|
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
|
|
|
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
|
|
|
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
|
|
|
|
<button class="btn ico danger last" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
|
|
|
|
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>` : `
|
|
|
|
|
<div class="fhead slim">
|
|
|
|
|
@@ -856,8 +903,8 @@ function renderFeed(){
|
|
|
|
|
</div>`) + `
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
<div class="tabs">
|
|
|
|
|
${['all','unread','downloaded','flagged'].map(t=>
|
|
|
|
|
`<button data-f="${t}" class="${S.filter===t?'on':''}">${t[0].toUpperCase()+t.slice(1)}</button>`).join('')}
|
|
|
|
|
${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Kept']].map(([t,label])=>
|
|
|
|
|
`<button data-f="${t}" class="${S.filter===t?'on':''}">${label}</button>`).join('')}
|
|
|
|
|
</div>
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
@@ -868,7 +915,7 @@ function renderFeed(){
|
|
|
|
|
'<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') || '40%');
|
|
|
|
|
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
|
|
|
|
|
dragSplit(pane);
|
|
|
|
|
showDetail(null);
|
|
|
|
|
|
|
|
|
|
@@ -907,12 +954,11 @@ function renderGroup(f,kids){
|
|
|
|
|
// The same header as a feed's, buttons in the same places: it is a feed underneath.
|
|
|
|
|
$('#content').innerHTML = `
|
|
|
|
|
<div class="fhead slim">
|
|
|
|
|
${artHTML(f.image,f.title||f.id)}
|
|
|
|
|
${folderArt(f,kids)}
|
|
|
|
|
<div class="meta">
|
|
|
|
|
<h2>${esc(f.title||f.id)}</h2>
|
|
|
|
|
<div class="sub stat">${isPatreon(f)?'Patreon creator':'OPML subscription'} · ${kids.length} feed${kids.length===1?'':'s'}
|
|
|
|
|
· ${unread} unread · ${saved} downloaded · checked ${ago(f.last_checked)}
|
|
|
|
|
· every ${everyText(f.every_mins)}</div>
|
|
|
|
|
<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>
|
|
|
|
|
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
|
|
|
|
|
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
|
|
|
|
|
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
|
|
|
|
@@ -921,7 +967,7 @@ function renderGroup(f,kids){
|
|
|
|
|
<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 last" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
|
|
|
|
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
@@ -940,9 +986,9 @@ function renderGroup(f,kids){
|
|
|
|
|
el.className='childrow';
|
|
|
|
|
el.innerHTML = artHTML(c.image,c.title||c.id)+
|
|
|
|
|
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
|
|
|
|
|
`<small class="meta">${c.entries} items · ${c.downloaded} downloaded`+
|
|
|
|
|
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
|
|
|
|
|
(c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
|
|
|
|
(c.orphaned?'<span class="tag">gone</span>':'')+
|
|
|
|
|
(c.orphaned?'<span class="tag">Gone</span>':'')+
|
|
|
|
|
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
|
|
|
|
|
el.onclick=()=>selectFeed(c.id);
|
|
|
|
|
box.appendChild(el);
|
|
|
|
|
@@ -1016,16 +1062,15 @@ function epEl(e){
|
|
|
|
|
const playable=isPlayable(enc);
|
|
|
|
|
const others=e.enclosures.length-1;
|
|
|
|
|
const el=document.createElement('div');
|
|
|
|
|
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'')+
|
|
|
|
|
(player.guid===e.guid?' playing':'');
|
|
|
|
|
el.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?ICON.play:(e.read?'':'●')}</button>
|
|
|
|
|
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
|
|
|
|
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${
|
|
|
|
|
e.flagged?'Stop keeping':'Keep (never auto-delete)'}">${e.flagged?ICON.flagOn:ICON.flag}</button>
|
|
|
|
|
e.flagged?'Stop keeping':'Keep, so it is never deleted'}">${e.flagged?ICON.flagOn:ICON.flag}</button>
|
|
|
|
|
<div class="body">
|
|
|
|
|
<span class="t">${esc(e.title||'(untitled)')}</span>
|
|
|
|
|
<div class="line">${[
|
|
|
|
|
@@ -1160,6 +1205,8 @@ function syncTools(e){
|
|
|
|
|
/// 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){
|
|
|
|
|
@@ -1307,7 +1354,8 @@ function play(e,enc=e.enclosures.find(isPlayable)){
|
|
|
|
|
audio.src=`/media/${enc.id}`;
|
|
|
|
|
audio.currentTime=0;
|
|
|
|
|
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
|
|
|
|
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title);
|
|
|
|
|
// 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):'';
|
|
|
|
|
@@ -1347,8 +1395,9 @@ function savePos(){
|
|
|
|
|
}
|
|
|
|
|
audio.addEventListener('pause',savePos);
|
|
|
|
|
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
|
|
|
|
|
audio.addEventListener('play',()=>$('#pplay').innerHTML=ICON.pause);
|
|
|
|
|
audio.addEventListener('pause',()=>$('#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;
|
|
|
|
|
@@ -1626,7 +1675,7 @@ async function prefsModal(){
|
|
|
|
|
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
|
|
|
|
|
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
|
|
|
|
|
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
|
|
|
|
|
<span class="hint">Over this, the oldest played items are deleted first. Starred
|
|
|
|
|
<span class="hint">Over this, the oldest played items are deleted first. Kept
|
|
|
|
|
items are never touched.</span></div>
|
|
|
|
|
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
|
|
|
|
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
|
|
|
|
@@ -1634,8 +1683,9 @@ async function prefsModal(){
|
|
|
|
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
|
|
|
|
<div class="field"><label>Subscriptions</label>
|
|
|
|
|
<div class="inline">
|
|
|
|
|
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
|
|
|
|
|
<button class="btn ico" id="gopml" title="Import OPML…" aria-label="Import OPML">${ICON.plus}</button>
|
|
|
|
|
<!-- 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>
|
|
|
|
|
@@ -1666,7 +1716,7 @@ async function usersModal(){
|
|
|
|
|
openModal(`<h3>Users</h3>
|
|
|
|
|
${users.map(u=>`<div class="inline" data-id="${u.id}" style="margin-bottom:8px">
|
|
|
|
|
<b style="flex:1;overflow-wrap:anywhere">${esc(u.name)}</b>
|
|
|
|
|
${u.password?'':'<span class="tag" title="No password: signs in through the proxy">proxy</span>'}
|
|
|
|
|
${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>
|
|
|
|
|
@@ -1840,6 +1890,17 @@ api('/api/me').then(u=>{
|
|
|
|
|
}).catch(()=>{});
|
|
|
|
|
on('#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, and the 2004 Mac app. The button steps through them; the choice is remembered.
|
|
|
|
|
|