DeadAir in the page: a switch per show, one beside the speed, and the time saved (#172)

A DeadAir switch in each show's settings, and the same switch beside
the speed for the show that is playing, drawn as a waveform with its gap
closed up, outlined when off and solid when on, with aria-pressed. A
file the server gives a copy for plays from /media/{id}/deadair. An
episode's line says how much shorter it is, and Settings says how much
DeadAir has saved you.

Turning it on or off mid-episode saves the place with a request it waits
for, not a beacon, then asks for the entry again from the new
GET /api/entries/{feed}/{guid}, whose position is in the clock of the
version it now plays, and carries on there. A copy not made yet plays
from the next time. Turning a show on wakes the copy maker, so the copy
is there in seconds rather than at its next pass.

The browser test plays a real MP3 with long silences, quiet.mp3, from a
show it adds and removes itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 18:18:41 +00:00
parent 16b0686aab
commit 827cc7489f
13 changed files with 194 additions and 10 deletions

View File

@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- DeadAir, on the server: in each feed you turn it on for, episodes get a copy with their long silences shortened, so podcasts finish sooner without sounding sped up, and your place carries over between the copy and the original. It is off for every feed until you do; the setting in the page comes next (#171).
- DeadAir: turn it on for a show in its settings, or with the button beside the speed while it plays, and its episodes play with long silences shortened, so podcasts finish sooner without sounding sped up. Your place carries over when you turn it on or off, each episode says how much shorter it is, and Settings shows the time it has saved you. It is off for every show until you turn it on (#171, #172).
### Fixed

View File

@@ -1061,12 +1061,22 @@ impl Args {
/// The FROM and WHERE that `entries_in` and `count_in` share: whose read state, which feeds
/// (one, or every feed the person subscribes to), the filter, and a case-insensitive search of
/// title and description.
fn entries_from(a: &mut Args, user_id: i64, feed_id: Option<&str>, filter: Filter, search: Option<&str>) -> String {
fn entries_from(
a: &mut Args,
user_id: i64,
feed_id: Option<&str>,
filter: Filter,
search: Option<&str>,
guid: Option<&str>,
) -> String {
let user = a.p(user_id);
let scope = match feed_id {
let mut scope = match feed_id {
Some(f) => format!("e.feed_id = {}", a.p(f)),
None => format!("e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = {user})"),
};
if let Some(g) = guid {
scope = format!("{scope} AND e.guid = {}", a.p(g));
}
let search = match search.map(|q| q.trim().to_lowercase()).filter(|q| !q.is_empty()) {
Some(q) => {
let like = a.p(format!("%{q}%"));
@@ -1198,9 +1208,31 @@ impl Db {
offset: i64,
limit: i64,
order: &str,
) -> Result<Vec<EntryRow>> {
self.entries_where(user_id, feed_id, filter, search, None, offset, limit, order).await
}
/// One entry as `entries_in` gives it: what the player asks for again when the clock it
/// plays in changes (DeadAir, #171).
pub async fn entry(&self, user_id: i64, feed_id: &str, guid: &str) -> Result<Option<EntryRow>> {
let order = order_sql("published", "desc");
Ok(self.entries_where(user_id, Some(feed_id), Filter::All, None, Some(guid), 0, 1, &order).await?.pop())
}
#[allow(clippy::too_many_arguments)]
async fn entries_where(
&self,
user_id: i64,
feed_id: Option<&str>,
filter: Filter,
search: Option<&str>,
guid: Option<&str>,
offset: i64,
limit: i64,
order: &str,
) -> Result<Vec<EntryRow>> {
let mut a = Args::default();
let from = entries_from(&mut a, user_id, feed_id, filter, search);
let from = entries_from(&mut a, user_id, feed_id, filter, search, guid);
let sql = format!(
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description,
coalesce(s.read, false) AS read, coalesce(s.flagged, false) AS flagged, e.image,
@@ -1263,7 +1295,7 @@ impl Db {
search: Option<&str>,
) -> Result<i64> {
let mut a = Args::default();
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search));
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search, None));
let r = self.rows(&sql, a.0).await?;
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
}

View File

@@ -252,6 +252,7 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
}
tokio::select! {
_ = stop.changed() => return,
_ = ctx.deadair.notified() => {}
_ = tokio::time::sleep(IDLE) => {}
}
}

View File

@@ -153,6 +153,9 @@ pub struct Ctx {
// own, and a change in flight here can still store over theirs; take the copy from the
// database rather than memory if that ever happens.
pub cfg_write: tokio::sync::Mutex<()>,
/// Wakes DeadAir's maker when a show is turned on, so its copies start at once instead of
/// at the next pass (#171).
pub deadair: tokio::sync::Notify,
}
/// A copy of the config to change, holding off every other change until `store_cfg` keeps it.
@@ -345,6 +348,7 @@ async fn main() -> Result<()> {
detach_torrents: is_daemon,
retry: Default::default(),
cfg_write: Default::default(),
deadair: Default::default(),
});
let result = match cli.command {
@@ -2458,6 +2462,7 @@ mod tests {
detach_torrents: false,
retry: Default::default(),
cfg_write: Default::default(),
deadair: Default::default(),
}
}

View File

@@ -46,6 +46,7 @@ pub fn router(state: WebState) -> Router {
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
.route("/api/feeds/{id}/entries", get(entries))
.route("/api/entries", get(all_entries))
.route("/api/entries/{feed_id}/{guid}", get(one_entry))
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
.route("/api/feeds/{id}/read-all", post(read_all))
@@ -1458,6 +1459,25 @@ async fn entry_page(
Ok(Json(EntryPage { total, entries: rows }))
}
/// One entry, in the clock of the file this person will play (`deadair::for_reader`).
async fn one_entry(
State(state): State<WebState>,
Path((feed_id, guid)): Path<(String, String)>,
user: crate::db::User,
) -> Result<Json<crate::db::EntryRow>, ApiError> {
subscribed(&state, &user, &feed_id).await?;
let Some(row) = state.ctx.db.entry(user.id, &feed_id, &guid).await? else {
return Err(ApiError::not_found("no such entry"));
};
let mut rows = [row];
crate::deadair::for_reader(&state.ctx, user.id, &mut rows).await?;
let [mut row] = rows;
if let Some(d) = &row.description {
row.description = Some(clean_description(&mut feed_sanitizer(), d, row.link.as_deref()));
}
Ok(Json(row))
}
/// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
fn feed_sanitizer() -> ammonia::Builder<'static> {
let mut b = ammonia::Builder::new();
@@ -1600,6 +1620,7 @@ async fn patch_feed(
}
if let Some(wanted) = body.deadair {
state.ctx.db.set_deadair(user.id, &id, wanted).await?;
state.ctx.deadair.notify_one();
}
// What one person wants -- which items, whether to fetch them, how many at a time --
// is theirs. It goes on their subscription and nobody else sees the change.

View File

@@ -1750,3 +1750,45 @@ test('a feed\'s settings say what downloads, take words as chips, and keep what
await expect(page.locator('#skw .chip, #sblock .chip')).toHaveCount(0);
await page.keyboard.press('Escape');
});
test('DeadAir is off until turned on for a show, then plays its shortened copy and switches back', async ({ page }) => {
// #171/#172. A show of its own, with real audio, added here and left at the end.
const { id } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } })).json();
const entry = async () => {
const r = await page.request.get(`/api/feeds/${encodeURIComponent(id)}/entries`);
return (await r.json()).entries?.[0];
};
await expect.poll(async () => (await entry())?.guid, { timeout: 15_000 }).toBe('quiet-1');
await page.request.post(`/api/feeds/${encodeURIComponent(id)}/download-latest`, { data: { count: 1 } });
await expect.poll(async () => !!(await entry())?.enclosures[0]?.path, { timeout: 15_000 }).toBe(true);
// Off for a show until someone turns it on: no copy is made.
expect((await entry()).enclosures[0].deadair).toBeUndefined();
await page.reload();
// On, in the show's settings.
await page.locator('#feedlist .feed', { hasText: 'Quiet Show' }).first().click();
await page.locator('#content .acts [data-a="settings"]').click();
const sw = page.locator('#sdead');
await expect(sw).not.toBeChecked();
await sw.locator('xpath=..').click();
await page.locator('#ssave').click();
// Made at once, not at the next pass: about 1.8 s off each of four 2 s silences.
await expect.poll(async () => (await entry()).enclosures[0].deadair ?? 0, { timeout: 20_000 }).toBeGreaterThan(5);
await page.reload();
await page.locator('#feedlist .feed', { hasText: 'Quiet Show' }).first().click();
const row = page.locator('.ep', { hasText: 'Pauses Between' });
await expect(row).toContainText('s shorter');
// Played from the copy, with the switch beside the speed pressed.
await row.locator('[data-a=play]').click();
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').getAttribute('src'))).toMatch(/\/deadair$/);
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'true');
// Off from the player: the original, from the same place.
await page.locator('#pdead').click();
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'false');
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').getAttribute('src'))).toMatch(/\/media\/\d+$/);
expect((await entry()).enclosures[0].deadair).toBeUndefined();
await page.locator('#pclose').click();
await page.request.delete(`/api/feeds/${encodeURIComponent(id)}`);
});

BIN
tests/ui/fixtures/quiet.mp3 Normal file

Binary file not shown.

View File

@@ -0,0 +1,8 @@
<?xml version="1.0"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel><title>Quiet Show</title><link>http://127.0.0.1:8792/</link><description>Real audio with long silences, for DeadAir.</description>
<item><title>Pauses Between</title><guid>quiet-1</guid>
<pubDate>Mon, 15 Sep 2026 10:00:00 +0000</pubDate>
<description>A second of tone, then two of silence, five times.</description>
<enclosure url="http://127.0.0.1:8792/quiet.mp3" length="60518" type="audio/mpeg"/></item>
</channel></rss>

View File

@@ -66,6 +66,7 @@
</div>
</div>
<div id="pright">
<button class="iconbtn" id="pdead" aria-pressed="false" aria-label="DeadAir" hidden></button>
<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>

View File

@@ -363,7 +363,15 @@ function due(ts){
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
function deadSavedText(me){
const s=me&&me.deadair_saved||0;
return s?`It has saved you ${spanText(s)}, on episodes you heard three quarters of or more.`
:'Turn it on in a show\'s settings, or beside the speed while it plays, to shorten its long silences.';
}
async function prefsModal(){
// Fresh, so the time saved counts what was heard since the page loaded.
api('/api/me').then(u=>{ S.me=u; const h=$('#sdeadsaved'); if(h) h.textContent=deadSavedText(u); }).catch(()=>{});
const g = await api('/api/settings');
const admin = !!(S.me&&S.me.admin);
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
@@ -374,6 +382,8 @@ async function prefsModal(){
<select id="smode">${Object.entries(MODES).map(([k,t])=>
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
<span class="hint">Auto follows your system's light/dark setting.</span></div>
<div class="field"><label>DeadAir</label>
<span class="hint" id="sdeadsaved">${deadSavedText(S.me)}</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. -->
@@ -517,6 +527,9 @@ function settingsModal(f, newUrl?: string){
<p class="cap">Any one of these will do, and a phrase needs all its words. Empty takes everything.</p></div>
${sw(`<input type="checkbox" role="switch" id="sexp"${f.allow_explicit?' checked':''}>`,'Include explicit items')}
</div></div></div></div>
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
<p class="cap">Plays ${isGroup?'these shows':'this show'} with long silences shortened, so episodes finish
sooner without sounding sped up. Leave it off for readings whose pauses matter.</p></div>
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
@@ -600,7 +613,7 @@ function settingsModal(f, newUrl?: string){
const patch: Record<string, unknown>={
keywords:chipWords($('#skw')),
max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked};
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
// API refuses it from anyone else. The folder and category are not this dialog's at all.

View File

@@ -78,6 +78,7 @@ function epEl(e){
num&&`<span>${num}</span>`,
left&&`<span>${left}</span>`,
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
enc&&enc.deadair>0&&`<span title="DeadAir shortened its long silences">${shorter(enc.deadair)}</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>

View File

@@ -1,8 +1,12 @@
/* ---------------- player ---------------- */
const audio=$('#audio');
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, moved?: boolean,
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, src?: string, moved?: boolean,
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
/// Where a file plays from: DeadAir's copy when the server says this person plays it (#171),
/// whose times the entry is already given in, else the file itself.
const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair`:`/media/${enc.id}`;
// 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.
@@ -20,14 +24,15 @@ function play(e,enc=e.enclosures.find(isPlayable)){
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;
// The same file carries on where it was; another of the item's files starts from its top. So
// does the other version of the same file, from the place the entry gives in its clock.
const resuming = player.guid===e.guid && player.enc===enc.id && player.src===mediaSrc(enc);
// Every play button is a pause button for what is playing, as the player bar's is.
if(resuming && !audio.paused){ audio.pause(); return; }
if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false;
document.body.classList.toggle('has-video', kindOf(enc)==='video');
audio.src=`/media/${enc.id}`;
audio.src=player.src=mediaSrc(enc);
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.
@@ -38,6 +43,7 @@ function play(e,enc=e.enclosures.find(isPlayable)){
$('#player').classList.add('on');
mediaSession(e,f);
player.marked=false;
paintDead();
}
audio.play().catch(err=>toast('Playback failed: '+err.message,true));
renderEntries();
@@ -110,6 +116,49 @@ $('#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)};
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
/// icon's shape and aria-pressed, never a colour.
function paintDead(){
const b=$('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
b.hidden=!f||document.body.classList.contains('has-video');
if(b.hidden) return;
b.setAttribute('aria-pressed',String(!!f.deadair));
b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair;
b.title=f.deadair?'DeadAir is on for this show: long silences are shortened'
:'Turn on DeadAir for this show: long silences are shortened';
}
$('#pdead').onclick=async()=>{
const f=S.feeds.find(x=>x.id===player.feed); if(!f) return;
const on=!f.deadair;
try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); }
catch(err){ toast(err.message,true); return; }
f.deadair=on; paintDead();
const switched=await switchVersion();
toast(!on?'DeadAir is off for this show':switched?'DeadAir is on for this show'
:'DeadAir is on for this show. Each episode is shortened a few minutes after it downloads.');
renderEntries();
};
/// Carries on at the same place in the other version of what is playing, once DeadAir has been
/// turned on or off for its show. True if it changed version: a copy not made yet plays next time.
async function switchVersion(){
if(!player.guid) return false;
const [feed,guid]=[player.feed,player.guid].map(encodeURIComponent);
// Saved and waited for, not beaconed, so the entry asked for next has this place in it.
if(audio.readyState){
await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({
secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{});
player.moved=false;
}
const e=await api(`/api/entries/${feed}/${guid}`).catch(()=>null);
const enc=e&&e.enclosures.find(x=>x.id===player.enc);
if(!enc||mediaSrc(enc)===player.src) return false;
const was=!audio.paused;
player.entry=e; player.src=mediaSrc(enc); player.saveAt=e.position;
audio.src=player.src;
audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
if(was) audio.play().catch(()=>{});
return true;
}
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
// Called here, not left to the pause event: closing a player already paused fires none.
syncListening()};

View File

@@ -21,6 +21,10 @@ const ICON={
// 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
// DeadAir (#171): a waveform with the gap in its middle closed up. Outlined is off for this
// show, solid is on, as pin and pinOn; padded like them for the outline's stroke.
deadair:fa('-16 -16 544 544','<g fill="none" stroke="currentColor" stroke-width="32"><rect x="16" y="200" width="48" height="112" rx="24"/><rect x="96" y="136" width="48" height="240" rx="24"/><rect x="176" y="96" width="48" height="320" rx="24"/><rect x="288" y="96" width="48" height="320" rx="24"/><rect x="368" y="136" width="48" height="240" rx="24"/><rect x="448" y="200" width="48" height="112" rx="24"/></g>'),
deadairOn:fa('-16 -16 544 544','<g fill="currentColor"><rect x="16" y="200" width="48" height="112" rx="24"/><rect x="96" y="136" width="48" height="240" rx="24"/><rect x="176" y="96" width="48" height="320" rx="24"/><rect x="288" y="96" width="48" height="320" rx="24"/><rect x="368" y="136" width="48" height="240" rx="24"/><rect x="448" y="200" width="48" height="112" rx="24"/></g>'),
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
@@ -56,6 +60,13 @@ const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
// 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]);
/// What DeadAir took off an episode, as the list says it.
function shorter(secs){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; }
/// A total of time saved: "3 h 12 min", "12 min", "40 s".
function spanText(secs){
const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60);
return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`;
}
async function api(url: string, opts?: RequestInit): Promise<any>{
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }