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||'')+ - `