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 0000000..744929f Binary files /dev/null and b/tests/ui/fixtures/quiet.mp3 differ 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'); }