Files pane plays through the player bar, once

The pane drew its own <audio controls> for a downloaded file, and its
onplay also started the player bar, so one click played the same file
twice at once. The pane now has a play button (with the file's type
icon, like the other rows) that hands that exact file to the player
bar, the only player. play() takes the file, so another of an item's
files starts from its top instead of resuming the first. Dead CSS for
the pane's <audio> removed.

Test: play in the Files pane leaves one <audio> on the page, the bar's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173mGu6rK18Ne7UGTwAaVJV
This commit is contained in:
2026-09-11 17:30:03 +00:00
parent 5362436766
commit 669e8b5124
3 changed files with 29 additions and 18 deletions

View File

@@ -61,6 +61,8 @@ The long form, with what was wrong before and how it was found, is in
### Fixed
- Playing a file from the Files pane played it twice at once, in the pane and in the player bar.
The pane has a play button now, and the player bar is the only player.
- The password box in Manage users was white in the dark theme.
- An item with no date showed a stray dot in its details.
- Escape did not close a dialog while the cursor was in one of its boxes, so Add feed, which opens

View File

@@ -89,13 +89,13 @@ test('the three panes are there and the item text lands in the bottom one', asyn
// its chip rather than assuming which episode the daemon happened to fetch.
const downloaded = page.locator('.ep', { has: page.locator('.kind.here') }).first();
await downloaded.click();
await expect(page.locator('#files audio')).toBeVisible();
await expect(page.locator('#files [data-a="play"]')).toBeVisible();
await expect(page.locator('#files .encbox [title="Save to this computer"]')).toBeVisible();
// Selecting another item replaces the pane rather than stacking.
await page.locator('.ep', { hasText: 'First Episode' }).click();
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
await expect(page.locator('#files audio')).toHaveCount(0);
await expect(page.locator('#files [data-a="play"]')).toHaveCount(0);
});
test('a downloaded file that is not audio gets no player', async ({ page }) => {
@@ -109,7 +109,7 @@ test('a downloaded file that is not audio gets no player', async ({ page }) => {
await row.click();
await expect(page.locator('#detail .dt')).toHaveText('An Article');
await expect(page.locator('#files audio')).toHaveCount(0);
await expect(page.locator('#files [data-a="play"]')).toHaveCount(0);
// What it is and that it is here: one icon, green, with the words in its tooltip.
await expect(page.locator('#files .encbox .kind.here')).toHaveAttribute('title', 'image, downloaded');
// Still offered as a file, just not as an episode: viewable and keepable.
@@ -767,3 +767,14 @@ test('the item table sorts by any column, both ways, and remembers', async ({ pa
await expect(page.locator('#eps .ep .size', { hasText: /\d/ }).first()).toBeVisible();
await expect(page.locator('#eps .ep .file', { hasText: /\d/ })).toHaveCount(0);
});
test('play in the Files pane plays once, in the player bar', async ({ page }) => {
// Regression: the pane had an <audio> of its own, and playing it started the player bar too,
// so the same file played twice at once.
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
await page.locator('.ep', { has: page.locator('.kind.here') }).first().click();
await page.locator('#files [data-a="play"]').click();
await expect(page.locator('#player')).toBeVisible();
await expect(page.locator('audio')).toHaveCount(1); // the player bar's, and nothing else
await page.locator('#pclose').click();
});

View File

@@ -191,7 +191,6 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
/* The selected item's files, beside the list, as the original's Files pane was. */
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
#files .encbox{margin-top:8px}
#files .encbox audio{min-width:0;width:100%;flex-basis:100%}
#files .empty{padding:24px 0}
.fhd{font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint)}
#grab{grid-area:grab;cursor:row-resize;background:var(--line)}
@@ -208,7 +207,6 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px;
padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);
}
.encbox audio{flex:1;min-width:200px;margin:0}
.fhead{display:flex;gap:18px;margin-bottom:18px}
.fhead .art{width:118px;height:118px;font-size:34px;box-shadow:var(--shadow)}
.fhead .meta{min-width:0;flex:1;display:flex;flex-direction:column}
@@ -1192,13 +1190,9 @@ function showDetail(e){
$('#dback').onclick=()=>showDetail(null);
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
for(const x of e.enclosures){
const a=$(`#audio-${x.id}`);
if(a) a.onplay=()=>play(e);
}
}
/// One enclosure: a player when the file is here, otherwise what it is and a way to get it.
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
function encBox(x){
const size=x.length?mb(x.length):'';
// One file serves everyone reading the feed, so deleting is not a private act.
@@ -1221,9 +1215,12 @@ function encBox(x){
</div>`;
}
if(x.path){
// One player, the bar at the bottom. This pane had an <audio> of its own, and playing it
// started the bar as well, so the same file played twice at once.
return `<div class="encbox">
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
<span class="meta">${size}</span>
${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span>
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
${saveBtn}
${delBtn}
</div>`;
@@ -1247,7 +1244,7 @@ async function epAction(a,e,el,encId){
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{
if(a==='play') play(e);
if(a==='play') play(e, encId!=null ? enc : undefined);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
if(a==='read'){ e.read=!e.read; await api(path+'/flags',{method:'POST',body:JSON.stringify({read:e.read})}); redraw(); loadFeeds(true); }
if(a==='get'){
@@ -1294,15 +1291,16 @@ function markPlayed(){
{method:'POST',body:JSON.stringify({read:true})}).then(()=>loadFeeds(true)).catch(()=>{});
}
function play(e){
const enc=e.enclosures.find(isPlayable);
if(!enc){
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
function play(e,enc=e.enclosures.find(isPlayable)){
if(!isPlayable(enc)){
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
return;
}
const resuming = player.guid===e.guid;
// 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;
if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e;
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id;
audio.src=`/media/${enc.id}`;
audio.currentTime=0;
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});