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 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
|
|
||||||
### Changed
|
### 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,
|
- 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.
|
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.
|
- 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
|
### 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
|
- 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
|
opening an episode marks it read, so it usually showed nothing. An episode now leaves the list
|
||||||
once 90% of it has played.
|
once 90% of it has played.
|
||||||
|
|||||||
@@ -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();
|
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
|
||||||
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
|
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
|
||||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
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.
|
// Removing it forgets where you got to, so it is still gone on the next visit.
|
||||||
await row.locator('[data-a=remove]').click();
|
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 row.click();
|
||||||
await expect(page.locator('#player')).toBeVisible();
|
await expect(page.locator('#player')).toBeVisible();
|
||||||
await expect(page.locator('#ptitle')).toHaveText('Second Episode');
|
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 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 }) => {
|
test('the filter tabs change what is listed', async ({ page }) => {
|
||||||
|
|||||||
@@ -204,6 +204,25 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.childrow .art{width:32px;height:32px;font-size:12px}
|
.childrow .art{width:32px;height:32px;font-size:12px}
|
||||||
.childrow .txt{flex:1;min-width:0}
|
.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}
|
.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
|
/* 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
|
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. */
|
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();
|
if(d && audio.currentTime/d >= 0.9) markPlayed();
|
||||||
});
|
});
|
||||||
function savePos(){
|
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;
|
player.saveAt=audio.currentTime;
|
||||||
if(player.entry) player.entry.position=Math.floor(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
|
// 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.
|
// body.playing is what sets the EQ bars moving.
|
||||||
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
||||||
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('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);
|
window.addEventListener('beforeunload',savePos);
|
||||||
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
||||||
$('#pback').onclick=()=>audio.currentTime-=15;
|
$('#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};
|
$('#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)};
|
$('#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)};
|
$('#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(){
|
(function restore(){
|
||||||
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
||||||
if(r){$('#rate').value=r;audio.playbackRate=+r}
|
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
|
/// 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
|
/// 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){
|
async function renderListening(url,box){
|
||||||
let rows=[];
|
let rows=[];
|
||||||
try{ rows=(await api(url)).entries||[]; }catch{}
|
try{ rows=(await api(url)).entries||[]; }catch{}
|
||||||
@@ -1865,19 +1889,41 @@ async function renderListening(url,box){
|
|||||||
for(const e of rows){
|
for(const e of rows){
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
el.className='childrow';
|
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||'')+
|
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
||||||
`<div class="txt"><b>${esc(e.title||'(untitled)')}</b>`+
|
`<div class="txt"><b>${EQ}<span>${esc(e.title||'(untitled)')}</span></b>`+
|
||||||
`<small class="meta">${esc(feedName(e.feed_id))} · ${clock(e.position)} of ${e.duration?clock(e.duration):'?'}</small>`+
|
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
|
||||||
`<div class="dlbar live"><i style="width:${pct}%"></i></div></div>`+
|
`<button class="iconbtn" data-a="play"></button>`+
|
||||||
`<button class="btn ico primary" title="Resume" aria-label="Resume">${ICON.play}</button>`+
|
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
|
||||||
`<button class="btn ico" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`;
|
`<div class="rail"><i></i></div>`;
|
||||||
el.onclick=ev=>ev.target.closest('[data-a=remove]')?forget(e):play(e);
|
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);
|
box.appendChild(el);
|
||||||
}
|
}
|
||||||
return rows.length;
|
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
|
/// 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.
|
/// episode with a saved position short of the end, so the position is what has to go.
|
||||||
async function forget(e){
|
async function forget(e){
|
||||||
|
|||||||
Reference in New Issue
Block a user