From 827cc7489f7d31bb5fdb764543b00f891bfc27d4 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 9 Oct 2026 18:18:41 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 2 +- src/db.rs | 40 ++++++++++++++++++++++--- src/deadair.rs | 1 + src/main.rs | 5 ++++ src/web.rs | 21 +++++++++++++ tests/ui/app.spec.js | 42 ++++++++++++++++++++++++++ tests/ui/fixtures/quiet.mp3 | Bin 0 -> 60518 bytes tests/ui/fixtures/quiet.xml | 8 +++++ web/index.html | 1 + web/src/dialogs.ts | 15 +++++++++- web/src/items.ts | 1 + web/src/player.ts | 57 +++++++++++++++++++++++++++++++++--- web/src/util.ts | 11 +++++++ 13 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 tests/ui/fixtures/quiet.mp3 create mode 100644 tests/ui/fixtures/quiet.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ed4abe..812f5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/db.rs b/src/db.rs index f093c29..536618d 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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> { + 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> { + 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> { 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 { 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")?) } diff --git a/src/deadair.rs b/src/deadair.rs index 4dccff1..ec047b5 100644 --- a/src/deadair.rs +++ b/src/deadair.rs @@ -252,6 +252,7 @@ pub async fn run(ctx: Arc, mut stop: tokio::sync::watch::Receiver) { } tokio::select! { _ = stop.changed() => return, + _ = ctx.deadair.notified() => {} _ = tokio::time::sleep(IDLE) => {} } } diff --git a/src/main.rs b/src/main.rs index 41294e0..b403027 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(), } } diff --git a/src/web.rs b/src/web.rs index 5f24576..54ffe27 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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, + Path((feed_id, guid)): Path<(String, String)>, + user: crate::db::User, +) -> Result, 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. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 8a525dd..0e2fd88 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -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)}`); +}); diff --git a/tests/ui/fixtures/quiet.mp3 b/tests/ui/fixtures/quiet.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..744929fa705e43ec3e34297d0ab9a7402545a7d4 GIT binary patch literal 60518 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 literal 0 HcmV?d00001 diff --git a/tests/ui/fixtures/quiet.xml b/tests/ui/fixtures/quiet.xml new file mode 100644 index 0000000..c7fb5a9 --- /dev/null +++ b/tests/ui/fixtures/quiet.xml @@ -0,0 +1,8 @@ + + +Quiet Showhttp://127.0.0.1:8792/Real audio with long silences, for DeadAir. +Pauses Betweenquiet-1 + Mon, 15 Sep 2026 10:00:00 +0000 + A second of tone, then two of silence, five times. + + diff --git a/web/index.html b/web/index.html index 8425835..1beecf6 100644 --- a/web/index.html +++ b/web/index.html @@ -66,6 +66,7 @@
+ ${Object.entries(MODES).map(([k,t])=> ``).join('')} Auto follows your system's light/dark setting.
+
+ ${deadSavedText(S.me)}
@@ -517,6 +527,9 @@ function settingsModal(f, newUrl?: string){

Any one of these will do, and a phrase needs all its words. Empty takes everything.

${sw(``,'Include explicit items')}
+
${sw(``,'DeadAir',' lead')} +

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.

${isGroup?'':`
${chipsHTML(f.blocked||[],'sblockin')}

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={ 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. diff --git a/web/src/items.ts b/web/src/items.ts index f6f8cd2..4d4e01e 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -78,6 +78,7 @@ function epEl(e){ num&&`${num}`, left&&`${left}`, others>0&&`+${others} more file${others===1?'':'s'}`, + enc&&enc.deadair>0&&`${shorter(enc.deadair)}`, enc&&enc.last_error&&enc.state==='error'&&`${esc(enc.last_error)}`, ].filter(Boolean).join('')}

diff --git a/web/src/player.ts b/web/src/player.ts index 54338df..1eba2ab 100644 --- a/web/src/player.ts +++ b/web/src/player.ts @@ -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()}; diff --git a/web/src/util.ts b/web/src/util.ts index 21bf3dc..0102fe1 100644 --- a/web/src/util.ts +++ b/web/src/util.ts @@ -21,6 +21,10 @@ const ICON={ // for the outline's stroke, so the two draw the same size. pin:fa('-18 -18 420 548',''), // solid/thumbtack, outlined pinOn:fa('-18 -18 420 548',''), // 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',''), + deadairOn:fa('-16 -16 544 544',''), scan:fa('0 0 512 512',''), // solid/arrows-rotate download:fa('0 0 448 512',''), // solid/download save:fa('0 0 448 512',''), // solid/floppy-disk @@ -56,6 +60,13 @@ const EQ=''; // 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{ const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts}); if(r.status===401){ location.href='/login'; throw new Error('signed out'); }