From 8daa7a199130533e567b59bbe41bc9dd29d7261a Mon Sep 17 00:00:00 2001 From: rays Date: Tue, 15 Sep 2026 18:08:21 +0000 Subject: [PATCH] Currently Listening: the EQ bars mark what is playing The row in the player gets the amber EQ bars, as the item list's does, and its progress rail and time left move as it plays. Rows say how much is left, and their buttons are quiet so that row stands out. savePos no longer saves before the file has loaded: currentTime is 0 then, and a failed load or an early pause wiped the saved position. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 +++ tests/ui/app.spec.js | 7 ++++- web/index.html | 66 +++++++++++++++++++++++++++++++++++++------- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fad47a..703500d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ The long form, with what was wrong before and how it was found, is in ### Changed +- Currently Listening marks the episode in the player with the EQ bars, as the item list does, + and its progress and time left move as it plays. Each row says how much is left. - Directory shows each feed as its cover art in a grid, title and subscriber count underneath, instead of a list. Popular and the Add a feed dialog keep their rows. - The pages are set in Inter, served by ipx itself. Classic keeps Lucida Grande. @@ -36,6 +38,8 @@ The long form, with what was wrong before and how it was found, is in ### Fixed +- An episode that fails to load, or is paused before it has, no longer forgets where you left off + in it, and so no longer drops out of Currently Listening. - Currently Listening lists the episodes you have started. It left out anything marked read, and opening an episode marks it read, so it usually showed nothing. An episode now leaves the list once 90% of it has played. diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index a7aa2d1..1cc5427 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -174,7 +174,7 @@ test('Currently Listening, its own place below Popular, resumes an episode you s await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click(); const row = page.locator('#listening .childrow', { hasText: 'Second Episode' }); await expect(row).toBeVisible({ timeout: 20_000 }); - await expect(row).toContainText('0:42 of 15:00'); + await expect(row).toContainText('14:18 left'); // Removing it forgets where you got to, so it is still gone on the next visit. await row.locator('[data-a=remove]').click(); @@ -192,7 +192,12 @@ test('Currently Listening, its own place below Popular, resumes an episode you s await row.click(); await expect(page.locator('#player')).toBeVisible(); await expect(page.locator('#ptitle')).toHaveText('Second Episode'); + // The row in the player carries the EQ bars, as the feed view's does, until the player closes. + await expect(row).toHaveClass(/\bnow\b/); + await expect(row.locator('.eq')).toBeVisible(); await page.locator('#pclose').click(); + await expect(row).not.toHaveClass(/\bnow\b/); + await expect(row.locator('.eq')).toBeHidden(); }); test('the filter tabs change what is listed', async ({ page }) => { diff --git a/web/index.html b/web/index.html index 75a6910..7946f4a 100644 --- a/web/index.html +++ b/web/index.html @@ -204,6 +204,25 @@ input:focus,select:focus{outline:0;border-color:var(--accent)} .childrow .art{width:32px;height:32px;font-size:12px} .childrow .txt{flex:1;min-width:0} .childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +/* Currently Listening: how far into each episode you are, as a rail along the foot of its row. + The one in the player is marked as it is everywhere else, by the amber EQ bars, and its rail + and time left move as it plays. The rest stay neutral, so that one is what stands out. */ +#listening .childrow{position:relative;overflow:hidden;gap:12px;padding:10px 10px 13px} +#listening .childrow .art{width:44px;height:44px;font-size:13px} +#listening .txt b{display:flex;align-items:center;gap:7px} +#listening .txt b span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +#listening .txt small{display:flex;gap:12px;margin-top:3px;color:var(--faint);font-size:11.5px} +#listening .txt small .fd{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +#listening .txt small .left{flex:none;color:var(--dim)} +#listening .childrow:not(.now) .eq{display:none} +#listening .rail{position:absolute;left:0;right:0;bottom:0;height:3px;background:var(--raise)} +#listening .rail i{display:block;height:100%;width:0;background:var(--faint);transition:width .25s linear} +#listening .now{border-color:color-mix(in srgb,var(--accent2) 60%,var(--line))} +#listening .now .eq,#listening .now .left{color:var(--accent2)} +#listening .now .rail i{background:var(--accent2)} +/* Quiet buttons: a filled one on every row outshouted the row that is playing. */ +#listening [data-a=play]{color:var(--accent)} +#listening [data-a=remove]{color:var(--faint)} /* Directory's filters: .tabs for what a feed is, as the item filters are, and chips for what it is about. A picked chip is underlined in --accent2, as the download bar and the now-playing EQ are; a .badge's fill already means unread in the sidebar. */ @@ -1525,7 +1544,9 @@ audio.addEventListener('timeupdate',()=>{ if(d && audio.currentTime/d >= 0.9) markPlayed(); }); function savePos(){ - if(!player.guid) return; + // Before the file has loaded, currentTime is 0 rather than where you are: saving it then -- + // a failed load, or a pause before the seek to where you left off -- wiped the position. + if(!player.guid||!audio.readyState) return; player.saveAt=audio.currentTime; if(player.entry) player.entry.position=Math.floor(audio.currentTime); // The measured length stands in for one the feed left out: without it Currently Listening @@ -1540,6 +1561,7 @@ audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML // 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'); }); +for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening); window.addEventListener('beforeunload',savePos); $('#pplay').onclick=()=>audio.paused?audio.play():audio.pause(); $('#pback').onclick=()=>audio.currentTime-=15; @@ -1547,7 +1569,9 @@ $('#pfwd').onclick=()=>audio.currentTime+=30; $('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000}; $('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)}; $('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)}; -$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries()}; +$('#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()}; (function restore(){ const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol'); if(r){$('#rate').value=r;audio.playbackRate=+r} @@ -1857,7 +1881,7 @@ async function renderListed(v){ /// Currently Listening: episodes you started and have not finished, across every feed you /// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to -/// where you left off, not another way to browse. +/// where you left off, not another way to browse. The one in the player pauses instead. async function renderListening(url,box){ let rows=[]; try{ rows=(await api(url)).entries||[]; }catch{} @@ -1865,19 +1889,41 @@ async function renderListening(url,box){ for(const e of rows){ const el=document.createElement('div'); el.className='childrow'; - const pct=e.duration?Math.min(100,Math.round(e.position/e.duration*100)):0; + el.entry=e; el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+ - `
${esc(e.title||'(untitled)')}`+ - `${esc(feedName(e.feed_id))} ยท ${clock(e.position)} of ${e.duration?clock(e.duration):'?'}`+ - `
`+ - ``+ - ``; - el.onclick=ev=>ev.target.closest('[data-a=remove]')?forget(e):play(e); + `
${EQ}${esc(e.title||'(untitled)')}`+ + `${esc(feedName(e.feed_id))}
`+ + ``+ + ``+ + `
`; + el.onclick=ev=>ev.target.closest('[data-a=remove]')?forget(e) + :el.classList.contains('now')&&!audio.paused?audio.pause():play(e); + paintListenRow(el); box.appendChild(el); } return rows.length; } +/// One row's time left, progress and play button, taken from the player when it is the one in it. +function paintListenRow(el){ + const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id; + // Zero until the player has sought to where you left off; the saved position stands till then. + if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime); + const d=e.duration||(now&&isFinite(audio.duration)?Math.floor(audio.duration):0); + el.classList.toggle('now',now); + $('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`; + // With no length there is nothing to show, and an empty rail reads as a heavy border. + const rail=$('.rail',el); rail.hidden=!d; + $('i',rail).style.width=`${d?Math.min(100,e.position/d*100):0}%`; + const b=$('[data-a=play]',el), label=now&&!audio.paused?'Pause':'Resume'; + if(b.title!==label){ b.title=label; b.setAttribute('aria-label',label); b.innerHTML=label==='Pause'?ICON.pause:ICON.play; } +} +/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes. +function syncListening(){ + for(const el of $$('#listening .childrow')) + if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el); +} + /// Takes an episode off Currently Listening by forgetting where you got to: the list is every /// episode with a saved position short of the end, so the position is what has to go. async function forget(e){