Compare commits
1 Commits
main
...
file-where
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ef01593d7 |
@@ -118,8 +118,9 @@ ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element')
|
|||||||
|
|
||||||
// Kept on the phone (#425): no service worker in a WebView, so the button is the app's to answer.
|
// Kept on the phone (#425): no service worker in a WebView, so the button is the app's to answer.
|
||||||
const enc42 = { id: 42, deadair: 0, mime: 'audio/mpeg' };
|
const enc42 = { id: 42, deadair: 0, mime: 'audio/mpeg' };
|
||||||
ok(String(vm.runInContext('keepBtn(X)', Object.assign(ctx, { X: enc42 }))).includes('data-a="offline"'),
|
// ⬇ ends on the phone in the app, whatever the width (#457).
|
||||||
'no Keep button in the app, which has no service worker to keep a file');
|
ok(vm.runInContext('toDevice(X)', Object.assign(ctx, { X: enc42 })) === true,
|
||||||
|
'in the app, a file\'s download button did not bring it to the phone, which has no service worker to keep it');
|
||||||
since();
|
since();
|
||||||
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
|
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
|
||||||
ok(last('keep')?.url === '/media/42', 'Keep did not ask the app for the file');
|
ok(last('keep')?.url === '/media/42', 'Keep did not ask the app for the file');
|
||||||
|
|||||||
@@ -352,3 +352,19 @@ test('a Patreon feed or file carries your access, a post page does not (#455)',
|
|||||||
assert.equal(carries('https://www.patreon.com/glasscannon/posts/music-from-room-174604106'), false);
|
assert.equal(carries('https://www.patreon.com/glasscannon/posts/music-from-room-174604106'), false);
|
||||||
assert.equal(carries('https://example.com/ep.mp3'), false);
|
assert.equal(carries('https://example.com/ep.mp3'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a file\'s box says where it is: the publisher, on its way, iPX, this device (#457)', () => {
|
||||||
|
const where = (x: object) => run(`whereText(${JSON.stringify({ id: 7, deadair: 0, path: null, state: 'held', ...x })})`) as string;
|
||||||
|
assert.equal(where({}), 'Publisher only');
|
||||||
|
assert.equal(where({ state: 'pending' }), 'Queued for iPX');
|
||||||
|
assert.equal(where({ state: 'error' }), 'iPX could not download it');
|
||||||
|
run(`want(7, 'Ep')`);
|
||||||
|
assert.equal(where({ state: 'pending' }), 'Downloading to iPX, then this device');
|
||||||
|
run('unwant(7)');
|
||||||
|
assert.equal(where({ path: '/d/ep.mp3', state: 'done' }), 'On iPX');
|
||||||
|
run(`kept.add('/media/7')`);
|
||||||
|
assert.equal(where({ path: '/d/ep.mp3', state: 'done' }), 'On iPX and this device');
|
||||||
|
// iPX let its copy go; the one here stays.
|
||||||
|
assert.equal(where({ state: 'reaped' }), 'On this device');
|
||||||
|
run('kept.clear()');
|
||||||
|
});
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ test('the three panes are there and the item text lands in the bottom one', asyn
|
|||||||
const downloaded = page.locator('.ep', { has: page.locator('.kind.here') }).first();
|
const downloaded = page.locator('.ep', { has: page.locator('.kind.here') }).first();
|
||||||
await downloaded.click();
|
await downloaded.click();
|
||||||
await expect(page.locator('#files [data-a="play"]')).toBeVisible();
|
await expect(page.locator('#files [data-a="play"]')).toBeVisible();
|
||||||
await expect(page.locator('#files .encbox [title="Save to this computer"]')).toBeVisible();
|
await expect(page.locator('#files .encbox [title="Save a copy to this computer"]')).toBeVisible();
|
||||||
|
|
||||||
// Selecting another item replaces the pane rather than stacking.
|
// Selecting another item replaces the pane rather than stacking.
|
||||||
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
||||||
@@ -349,7 +349,7 @@ test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
|||||||
// What it is and that it is here: one icon, green, with the words in its tooltip.
|
// 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');
|
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.
|
// Still offered as a file, just not as an episode: viewable and keepable.
|
||||||
await expect(page.locator('#files [title="Save to this computer"]')).toBeVisible();
|
await expect(page.locator('#files [title="Save a copy to this computer"]')).toBeVisible();
|
||||||
const view = page.locator('#files a[title="View in a new tab"]');
|
const view = page.locator('#files a[title="View in a new tab"]');
|
||||||
await expect(view).toHaveAttribute('target', '_blank');
|
await expect(view).toHaveAttribute('target', '_blank');
|
||||||
await expect(view).toHaveAttribute('rel', /noopener/);
|
await expect(view).toHaveAttribute('rel', /noopener/);
|
||||||
@@ -860,9 +860,10 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
|
|||||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
await row.click();
|
await row.click();
|
||||||
|
|
||||||
// An icon now; what it does, and to whom, is in its tooltip.
|
// Under the file's menu (#457), saying what it does, and to whom.
|
||||||
const del = page.locator('#files button[data-a="del"]');
|
await page.locator('#files .encbox [data-a="more"]').last().click();
|
||||||
await expect(del).toHaveAttribute('title', /^Delete for everyone \(shared with 1 other person/);
|
const del = page.locator('#modalCard [data-m="del"]');
|
||||||
|
await expect(del).toHaveText(/^Delete from iPX, for everyone \(shared with 1 other person/);
|
||||||
|
|
||||||
// Two prompts: the page's own, then the server's, because someone else has not played
|
// Two prompts: the page's own, then the server's, because someone else has not played
|
||||||
// it. Accept the first, decline the second, and the file must survive.
|
// it. Accept the first, decline the second, and the file must survive.
|
||||||
@@ -1345,7 +1346,7 @@ test('a download by hand fills its bar and names its file, and only on its own s
|
|||||||
const slow = page.locator('.ep', { hasText: 'Slow episode' });
|
const slow = page.locator('.ep', { hasText: 'Slow episode' });
|
||||||
await expect(slow).toBeVisible({ timeout: 20_000 });
|
await expect(slow).toBeVisible({ timeout: 20_000 });
|
||||||
await slow.click();
|
await slow.click();
|
||||||
await page.locator('#files button[title="Download to the server"]').click();
|
await page.locator('#files button[title="Download to iPX"]').click();
|
||||||
await expect(slow.locator('.dlbar.live')).toBeAttached({ timeout: 10_000 });
|
await expect(slow.locator('.dlbar.live')).toBeAttached({ timeout: 10_000 });
|
||||||
await expect(page.locator('#count')).toContainText(/downloading .+ — \d+%/);
|
await expect(page.locator('#count')).toContainText(/downloading .+ — \d+%/);
|
||||||
await expect(page.locator('.toast', { hasText: /^Downloaded .+\.mp3/ })).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator('.toast', { hasText: /^Downloaded .+\.mp3/ })).toBeVisible({ timeout: 20_000 });
|
||||||
@@ -1358,7 +1359,7 @@ test('a download by hand fills its bar and names its file, and only on its own s
|
|||||||
// A download that fails says which item, and what to do (#304).
|
// A download that fails says which item, and what to do (#304).
|
||||||
const broken = page.locator('.ep', { hasText: 'Broken episode' });
|
const broken = page.locator('.ep', { hasText: 'Broken episode' });
|
||||||
await broken.click();
|
await broken.click();
|
||||||
await page.locator('#files button[title="Download to the server"]').click();
|
await page.locator('#files button[title="Download to iPX"]').click();
|
||||||
await expect(page.locator('.toast', { hasText: 'Unable to download "Broken episode". Try again with its download button.' }))
|
await expect(page.locator('.toast', { hasText: 'Unable to download "Broken episode". Try again with its download button.' }))
|
||||||
.toBeVisible({ timeout: 20_000 });
|
.toBeVisible({ timeout: 20_000 });
|
||||||
await expect(broken.locator('.dlbar.live')).toHaveCount(0);
|
await expect(broken.locator('.dlbar.live')).toHaveCount(0);
|
||||||
@@ -1499,14 +1500,16 @@ test('a deleted file looks as if it was never downloaded', async ({ page }) => {
|
|||||||
const row = page.locator('.ep', { hasText: 'An Article' });
|
const row = page.locator('.ep', { hasText: 'An Article' });
|
||||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
await row.click();
|
await row.click();
|
||||||
await page.locator('#files button[data-a="del"]').click();
|
await page.locator('#files .encbox [data-a="more"]').last().click();
|
||||||
|
await page.locator('#modalCard [data-m="del"]').click();
|
||||||
|
|
||||||
// No "reaped", no chip at all: just the way to get it again.
|
// No "reaped", no chip at all: just the way to get it again.
|
||||||
await expect(row.locator('.kind.here')).toHaveCount(0);
|
await expect(row.locator('.kind.here')).toHaveCount(0);
|
||||||
await expect(row).not.toContainText(/reaped/i);
|
await expect(row).not.toContainText(/reaped/i);
|
||||||
await row.click();
|
await row.click();
|
||||||
await expect(page.locator('#files')).not.toContainText(/reaped/i);
|
await expect(page.locator('#files')).not.toContainText(/reaped/i);
|
||||||
await expect(page.locator('#files button[title="Download to the server"]')).toBeVisible();
|
await expect(page.locator('#files button[title="Download to iPX"]')).toBeVisible();
|
||||||
|
await expect(page.locator('#files .encbox .meta')).toContainText('Publisher only');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('one action, one icon: the toolbar, the page and every dialog agree', async ({ page }) => {
|
test('one action, one icon: the toolbar, the page and every dialog agree', async ({ page }) => {
|
||||||
@@ -1923,7 +1926,8 @@ test.describe('on a phone', () => {
|
|||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
await page.locator('.tabs button', { hasText: 'Downloaded' }).first().click();
|
await page.locator('.tabs button', { hasText: 'Downloaded' }).first().click();
|
||||||
await page.locator('.ep').first().click();
|
await page.locator('.ep').first().click();
|
||||||
const del = page.locator('#detail [data-a="del"]');
|
// Delete is under the file's menu (#457), which sits above the notes.
|
||||||
|
const del = page.locator('#detail .encbox [data-a="more"]').first();
|
||||||
await expect(del).toBeInViewport();
|
await expect(del).toBeInViewport();
|
||||||
// Below a long set of notes it was screens down and looked missing (issue #21).
|
// Below a long set of notes it was screens down and looked missing (issue #21).
|
||||||
expect(await page.locator('#detail').evaluate(d =>
|
expect(await page.locator('#detail').evaluate(d =>
|
||||||
@@ -2130,6 +2134,31 @@ test.describe('an item with no files, on a phone', () => {
|
|||||||
await expect(page.locator('#detail')).not.toContainText('No files');
|
await expect(page.locator('#detail')).not.toContainText('No files');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('one press of a file\'s button brings it from the publisher, through iPX, to the phone (#457)', async ({ page }) => {
|
||||||
|
// A file iPX does not have: Test Show's downloaded one, deleted first, so the test owns it.
|
||||||
|
const enc = await page.evaluate(async () => {
|
||||||
|
const list = await api('/api/feeds/test-show/entries?filter=all') as ListPage;
|
||||||
|
const x = list.entries.flatMap(e => e.enclosures).find(x => x.path && playsKind(x))!;
|
||||||
|
await api(`/api/enclosures/${x.id}?force=true`, { method: 'DELETE' });
|
||||||
|
return x.id;
|
||||||
|
});
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
// The row of a file not on iPX carries its download bar, by the file's id.
|
||||||
|
await page.locator('.ep').filter({ has: page.locator(`.dlbar[data-bar="${enc}"]`) }).click();
|
||||||
|
const box = page.locator('#detail .encbox');
|
||||||
|
await expect(box.locator('.meta')).toContainText('Publisher only');
|
||||||
|
await box.locator('[data-a="want"]').click();
|
||||||
|
await expect(box.locator('.meta')).toHaveText(/On iPX and this device$/, { timeout: 20_000 });
|
||||||
|
await expect(box.locator('[data-a="more"][aria-pressed="true"]')).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => localStorage.getItem('ipx.wanted'))).toBe('[]');
|
||||||
|
// Taken off the phone from its menu; iPX keeps its copy.
|
||||||
|
await box.locator('[data-a="more"][aria-pressed="true"]').click();
|
||||||
|
await page.locator('#modalCard [data-m="offline"]').click();
|
||||||
|
await expect(box.locator('.meta')).toHaveText(/On iPX$/);
|
||||||
|
});
|
||||||
|
|
||||||
test('back and the item\'s actions stay at hand anywhere in a long post (#456)', async ({ page }) => {
|
test('back and the item\'s actions stay at hand anywhere in a long post (#456)', async ({ page }) => {
|
||||||
await page.locator('#burger').click();
|
await page.locator('#burger').click();
|
||||||
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
|
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
|
||||||
@@ -2585,9 +2614,10 @@ test('a downloaded file\'s buttons sit on one row in the files pane', async ({ p
|
|||||||
await expect(page.locator('.ep').first()).toBeVisible();
|
await expect(page.locator('.ep').first()).toBeVisible();
|
||||||
await page.evaluate(() => showDetail(S.entries.find(e => e.enclosures.some(x => x.path)) ?? null));
|
await page.evaluate(() => showDetail(S.entries.find(e => e.enclosures.some(x => x.path)) ?? null));
|
||||||
const box = page.locator('#files .encbox').first();
|
const box = page.locator('#files .encbox').first();
|
||||||
await expect(box.locator('.btn')).toHaveCount(5);
|
// Play, the one button that brings it here, share, and the rest under ⋯ (#457).
|
||||||
|
await expect(box.locator('.btn')).toHaveCount(4);
|
||||||
const tops = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top)));
|
const tops = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top)));
|
||||||
expect(new Set(tops).size, 'play, keep, save, share and delete wrapped onto two rows').toBe(1);
|
expect(new Set(tops).size, 'play, save, share and more wrapped onto two rows').toBe(1);
|
||||||
// And on a phone, where the pane is the whole width (#412 added keep).
|
// And on a phone, where the pane is the whole width (#412 added keep).
|
||||||
await page.setViewportSize({ width: 375, height: 800 });
|
await page.setViewportSize({ width: 375, height: 800 });
|
||||||
const phone = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top)));
|
const phone = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top)));
|
||||||
|
|||||||
@@ -146,12 +146,17 @@ test('an episode kept on the device plays from it offline, and its place waits t
|
|||||||
await page.locator('.ep', { hasText: 'Listen on a Plane' }).click();
|
await page.locator('.ep', { hasText: 'Listen on a Plane' }).click();
|
||||||
};
|
};
|
||||||
await open();
|
await open();
|
||||||
const keep = page.locator('#files [data-a="offline"]');
|
// Kept by its menu on a computer; the line in its box says where it is (#457).
|
||||||
|
const where = page.locator('#files .encbox .meta');
|
||||||
|
const menu = async (m: string) => {
|
||||||
|
await page.locator('#files .encbox [data-a="more"]').last().click();
|
||||||
|
await page.locator(`#modalCard [data-m="${m}"]`).click();
|
||||||
|
};
|
||||||
const row = page.locator('.ep', { hasText: 'Listen on a Plane' });
|
const row = page.locator('.ep', { hasText: 'Listen on a Plane' });
|
||||||
await expect(keep).toHaveAttribute('aria-pressed', 'false');
|
await expect(where).toHaveText(/On iPX$/);
|
||||||
await expect(row.locator('.kept')).toHaveCount(0);
|
await expect(row.locator('.kept')).toHaveCount(0);
|
||||||
await keep.click();
|
await menu('offline');
|
||||||
await expect(keep).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 });
|
await expect(where).toHaveText(/On iPX and this device$/, { timeout: 15_000 });
|
||||||
expect(await cacheSize(page, 'ipx-episodes')).toBe(1);
|
expect(await cacheSize(page, 'ipx-episodes')).toBe(1);
|
||||||
// Its row in the list says so too (#413).
|
// Its row in the list says so too (#413).
|
||||||
await expect(row.locator('.kept')).toHaveText('On this device');
|
await expect(row.locator('.kept')).toHaveText('On this device');
|
||||||
@@ -160,7 +165,7 @@ test('an episode kept on the device plays from it offline, and its place waits t
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await open();
|
await open();
|
||||||
// Still kept, as the page reads it from the cache at start.
|
// Still kept, as the page reads it from the cache at start.
|
||||||
await expect(keep).toHaveAttribute('aria-pressed', 'true');
|
await expect(where).toHaveText(/On iPX and this device$/);
|
||||||
await expect(row.locator('.kept')).toBeVisible();
|
await expect(row.locator('.kept')).toBeVisible();
|
||||||
await page.locator('#files [data-a="play"]').click();
|
await page.locator('#files [data-a="play"]').click();
|
||||||
await expect.poll(() => page.evaluate(() => audio.currentTime), { timeout: 15_000 }).toBeGreaterThan(0.3);
|
await expect.poll(() => page.evaluate(() => audio.currentTime), { timeout: 15_000 }).toBeGreaterThan(0.3);
|
||||||
@@ -180,8 +185,8 @@ test('an episode kept on the device plays from it offline, and its place waits t
|
|||||||
expect((await call(page, `/api/entries/${encodeURIComponent(id)}/offline-ep-1`)).position).toBeGreaterThanOrEqual(0);
|
expect((await call(page, `/api/entries/${encodeURIComponent(id)}/offline-ep-1`)).position).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
// Taken off the device again.
|
// Taken off the device again.
|
||||||
await keep.click();
|
await menu('offline');
|
||||||
await expect(keep).toHaveAttribute('aria-pressed', 'false');
|
await expect(where).toHaveText(/On iPX$/);
|
||||||
expect(await cacheSize(page, 'ipx-episodes')).toBe(0);
|
expect(await cacheSize(page, 'ipx-episodes')).toBe(0);
|
||||||
await expect(row.locator('.kept')).toHaveCount(0);
|
await expect(row.locator('.kept')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- A file has one button that brings it to where you are: on a phone or in the iOS app it ends on the device, with iPX fetching it first when it has not got it, and on a computer iPX fetches it and the button then saves a copy. A line beside the file's size says where it is (publisher only, queued for iPX, on iPX, on this device), and the rarer actions (remove from this device, delete from iPX, download to iPX only) are under ⋯ (#457).
|
||||||
- The admin page is laid out as a feed's settings are: each tab in named parts, numbers with their unit beside them, switches for on and off, Server's Save always in reach, and on a phone the tabs in a row of their own so all four fit (#414).
|
- The admin page is laid out as a feed's settings are: each tab in named parts, numbers with their unit beside them, switches for on and off, Server's Save always in reach, and on a phone the tabs in a row of their own so all four fit (#414).
|
||||||
- An item's title and text take four fifths of the reading pane's width, no longer running to its far edge (#360).
|
- An item's title and text take four fifths of the reading pane's width, no longer running to its far edge (#360).
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ function connect(){
|
|||||||
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
||||||
// everyone's feeds used to announce themselves to everyone.
|
// everyone's feeds used to announce themselves to everyone.
|
||||||
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
|
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
|
||||||
|
// On iPX now, in what this page holds too, so the open file's box says so at once rather
|
||||||
|
// than after the list comes again; and, asked for on this device, its copy here follows (#457).
|
||||||
|
for(const e of S.entries) for(const x of e.enclosures) if(x.id===ev.enclosure){ x.path=ev.path; x.state='done'; }
|
||||||
|
if(wanted.has(ev.enclosure)) followWanted({id: ev.enclosure, path: ev.path});
|
||||||
|
else syncKeepButtons();
|
||||||
refreshEntries();
|
refreshEntries();
|
||||||
}
|
}
|
||||||
// A copy finished: the episode's "Cutting dead air…" becomes how much shorter it is.
|
// A copy finished: the episode's "Cutting dead air…" becomes how much shorter it is.
|
||||||
@@ -50,6 +55,7 @@ function connect(){
|
|||||||
else if(ev.ev==='download_error'){
|
else if(ev.ev==='download_error'){
|
||||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
if(bar) bar.classList.remove('live');
|
if(bar) bar.classList.remove('live');
|
||||||
|
unwant(ev.enclosure);
|
||||||
// Which item, and what to do: the reason (ev.msg) is in the daemon's log (#304).
|
// Which item, and what to do: the reason (ev.msg) is in the daemon's log (#304).
|
||||||
const e=S.entries.find(x=>x.enclosures.some(n=>n.id===ev.enclosure));
|
const e=S.entries.find(x=>x.enclosures.some(n=>n.id===ev.enclosure));
|
||||||
toast(`Unable to download ${e?`"${entryName(e).text}"`:'a file'}. Try again with its download button.`,true); refreshEntries();
|
toast(`Unable to download ${e?`"${entryName(e).text}"`:'a file'}. Try again with its download button.`,true); refreshEntries();
|
||||||
|
|||||||
130
web/src/items.ts
130
web/src/items.ts
@@ -129,6 +129,7 @@ function showList(r: ListPage, append: boolean, asked: number){
|
|||||||
const back=append ? null : takeReopen();
|
const back=append ? null : takeReopen();
|
||||||
if(back && !entries.some(e=>entryKey(e)===entryKey(back))) entries=[back,...entries];
|
if(back && !entries.some(e=>entryKey(e)===entryKey(back))) entries=[back,...entries];
|
||||||
S.entries = entries;
|
S.entries = entries;
|
||||||
|
followWantedIn(entries);
|
||||||
renderEntries();
|
renderEntries();
|
||||||
if(back){ const e=S.entries.find(x=>entryKey(x)===entryKey(back)); if(e) selectEntry(e); }
|
if(back){ const e=S.entries.find(x=>entryKey(x)===entryKey(back)); if(e) selectEntry(e); }
|
||||||
}
|
}
|
||||||
@@ -219,7 +220,10 @@ function epEl(e: Entry){
|
|||||||
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
|
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
|
||||||
/// is just a broken player.
|
/// is just a broken player.
|
||||||
function isPlayable(x: Enclosure|undefined){
|
function isPlayable(x: Enclosure|undefined){
|
||||||
if(!x || !x.path) return false;
|
return !!x && !!x.path && playsKind(x);
|
||||||
|
}
|
||||||
|
/// Whether a file is audio or video, wherever it is: one not downloaded yet too.
|
||||||
|
function playsKind(x: {mime?: string|null, path?: string|null, url?: string}){
|
||||||
const m=(x.mime||'').toLowerCase();
|
const m=(x.mime||'').toLowerCase();
|
||||||
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
||||||
if(m) return false;
|
if(m) return false;
|
||||||
@@ -467,59 +471,88 @@ function textOf(e: Entry){
|
|||||||
return (e.description&&e.description.trim())||'<em>No show notes.</em>';
|
return (e.description&&e.description.trim())||'<em>No show notes.</em>';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
/// Where a file is, in the words its box says it in (#457): the publisher's alone, on its way to
|
||||||
|
/// iPX, on iPX, on this device as well.
|
||||||
|
function whereText(x: Enclosure){
|
||||||
|
const here=isKept(x), busy=keeping.has(keepUrl(x));
|
||||||
|
if(!x.path){
|
||||||
|
if(here) return 'On this device';
|
||||||
|
if(wanted.has(x.id)) return 'Downloading to iPX, then this device';
|
||||||
|
if(x.state==='pending') return 'Queued for iPX';
|
||||||
|
if(x.state==='error') return 'iPX could not download it';
|
||||||
|
return 'Publisher only';
|
||||||
|
}
|
||||||
|
return here ? 'On iPX and this device' : busy ? 'On iPX, downloading to this device' : 'On iPX';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether ⬇ ends on this device: a phone or the app, where the file is wanted in the hand, for a
|
||||||
|
/// file this device can play offline. A computer's ⬇ has iPX fetch it and saves a copy.
|
||||||
|
const toDevice=(x: Enclosure)=>canKeep() && playsKind(x)
|
||||||
|
&& (!!ipxHost || !!window.matchMedia?.('(max-width:820px)')?.matches);
|
||||||
|
|
||||||
|
/// One enclosure (#457): what it is, where it is, and one button that brings it to where you are.
|
||||||
|
/// Everything else, rarer and two of it destructive, is under ⋯.
|
||||||
function encBox(x: Enclosure){
|
function encBox(x: Enclosure){
|
||||||
const size=x.length?mb(x.length):'';
|
const size=x.length?mb(x.length):'';
|
||||||
// One file serves everyone reading the feed, so deleting is not a private act.
|
const btn=(a: string, label: string, icon: string, extra='')=>
|
||||||
const f=S.feeds.find(y=>y.id===x.feed_id);
|
`<button class="btn ico" data-a="${a}" data-enc="${x.id}" title="${label}" aria-label="${label}"${extra}>${icon}</button>`;
|
||||||
const shared=f&&f.subscribers>1;
|
|
||||||
// Icons, with the words in the tooltip and for screen readers.
|
|
||||||
const delLabel=shared
|
|
||||||
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
|
|
||||||
: 'Delete file';
|
|
||||||
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
|
||||||
// Played without its dead air, so saved without it too (#186).
|
// Played without its dead air, so saved without it too (#186).
|
||||||
const dead=(x.deadair??0)>0;
|
const dead=(x.deadair??0)>0;
|
||||||
const saveLabel=dead?'Save to this computer, without its dead air':'Save to this computer';
|
let get='';
|
||||||
const saveBtn=`<a class="btn ico" href="/media/${x.id}${dead?'/deadair':''}" download title="${saveLabel}" aria-label="${saveLabel}">${ICON.save}</a>`;
|
if(toDevice(x)){
|
||||||
// The publisher's address, not this server's copy, which only someone signed in here can open.
|
const url=keepUrl(x);
|
||||||
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
|
get=isKept(x) ? btn('more', 'On this device. More', ICON.kept, ' aria-pressed="true"')
|
||||||
if(x.path && !isPlayable(x)){
|
: keeping.has(url) ? btn('offline', 'Downloading to this device', ICON.download, ` data-url="${url}" aria-busy="true"`)
|
||||||
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
: wanted.has(x.id) ? btn('want', 'Downloading to iPX, then this device', ICON.download, ' aria-busy="true"')
|
||||||
return `<div class="encbox">
|
: x.path ? btn('offline', 'Download to this device', ICON.download, ` data-url="${url}"`)
|
||||||
${kindIcon(x)}
|
: btn('want', 'Download to this device', ICON.download);
|
||||||
<span class="meta" style="flex:1">${size}</span>
|
}else if(x.path){
|
||||||
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
|
const label=dead?'Save a copy to this computer, without its dead air':'Save a copy to this computer';
|
||||||
${saveBtn}
|
get=`<a class="btn ico" href="/media/${x.id}${dead?'/deadair':''}" download title="${label}" aria-label="${label}">${ICON.download}</a>`;
|
||||||
${shareBtn}
|
}else if(x.state!=='pending') get=btn('get', 'Download to iPX', ICON.download);
|
||||||
${delBtn}
|
// Nothing on disk, an image or a PDF: you usually just want to look at it, so the publisher's
|
||||||
</div>`;
|
// copy in a new tab, nothing proxied through here, which would make ipx a fetch-anything relay.
|
||||||
}
|
const view=x.path && !isPlayable(x) ? `/media/${x.id}`
|
||||||
if(x.path){
|
: !x.path && !!webUrl(x.url) && !isPlayable(x) && x.state!=='pending' && x.state!=='held' ? x.url : '';
|
||||||
// One player, the bar at the bottom. This pane had an <audio> of its own, and playing it
|
// 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.
|
// started the bar as well, so the same file played twice at once.
|
||||||
return `<div class="encbox">
|
return `<div class="encbox">
|
||||||
${kindIcon(x)}
|
${kindIcon(x)}
|
||||||
<span class="meta" style="flex:1">${size}</span>
|
<span class="meta" style="flex:1">${[size, whereText(x)].filter(Boolean).join(' · ')}</span>
|
||||||
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
|
${x.state==='error'&&x.last_error&&!x.path?`<span class="err">${esc(x.last_error)}</span>`:''}
|
||||||
${keepBtn(x)}
|
${x.path&&isPlayable(x)?btn('play', 'Play', ICON.play):''}
|
||||||
${saveBtn}
|
${view?`<a class="btn ico" href="${esc(view)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
|
||||||
${shareBtn}
|
${get}
|
||||||
${delBtn}
|
${/* The publisher's address, not this server's copy, which only someone signed in here can open. */''}
|
||||||
|
${btn('share', 'Share the file', ICON.share)}
|
||||||
|
${fileMenuItems(x).length?btn('more', 'More', ICON.more):''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
|
|
||||||
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
|
/// What ⋯ offers for a file: whatever its one button does not.
|
||||||
// through here, which would make ipx a fetch-anything relay.
|
function fileMenuItems(x: Enclosure): [string, string, string][]{
|
||||||
const viewable = !!webUrl(x.url) && !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
|
const items: [string, string, string][]=[];
|
||||||
return `<div class="encbox">
|
const device=toDevice(x);
|
||||||
${kindIcon(x)}
|
if(isKept(x)) items.push(['offline', 'Remove from this device', '']);
|
||||||
<span class="meta" style="flex:1">${size}</span>
|
else if(!device && canKeep() && isPlayable(x) && x.path && !keeping.has(keepUrl(x)))
|
||||||
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
items.push(['offline', 'Download to this device, to play offline', '']);
|
||||||
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
|
if(device && !x.path && x.state!=='pending' && !wanted.has(x.id)) items.push(['get', 'Download to iPX only', '']);
|
||||||
${shareBtn}
|
if(x.path){
|
||||||
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
|
// One file serves everyone reading the feed, so deleting is not a private act.
|
||||||
</div>`;
|
const f=S.feeds.find(y=>y.id===x.feed_id);
|
||||||
|
items.push(['del', f&&f.subscribers>1
|
||||||
|
? `Delete from iPX, for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
|
||||||
|
: 'Delete from iPX', ' danger']);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileMenu(e: Entry, x: Enclosure){
|
||||||
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
|
||||||
|
<h3>${esc(entryName(e).text)}</h3><p class="hint">${esc(whereText(x))}</p>
|
||||||
|
<div class="sharemenu">${fileMenuItems(x).map(([a,label,cls])=>
|
||||||
|
`<button class="btn${cls}" data-m="${a}">${esc(label)}</button>`).join('')}</div>`);
|
||||||
|
$$<HTMLButtonElement>('#modalCard [data-m]').forEach(b=>b.onclick=()=>{ closeModal(); epAction(b.dataset.m!, e, null, x.id); });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A shared link that starts at a time (#447): ipx-play's page reads `?t=` in seconds. Under five
|
/// A shared link that starts at a time (#447): ipx-play's page reads `?t=` in seconds. Under five
|
||||||
@@ -595,10 +628,15 @@ async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: numbe
|
|||||||
if(a==='share') await shareEpisode(e, encId??null);
|
if(a==='share') await shareEpisode(e, encId??null);
|
||||||
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); haptic('tap'); redraw(); }
|
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); haptic('tap'); redraw(); }
|
||||||
if(a==='read'){ await setRead(e,!e.read); haptic(e.read?'done':'tap'); redraw(); }
|
if(a==='read'){ await setRead(e,!e.read); haptic(e.read?'done':'tap'); redraw(); }
|
||||||
if(a==='get'){
|
if(a==='more' && enc) fileMenu(e, enc);
|
||||||
|
if(a==='get' || a==='want'){
|
||||||
if(!enc) return;
|
if(!enc) return;
|
||||||
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
|
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
|
||||||
toast('Queued: '+entryName(e).text);
|
enc.state='pending';
|
||||||
|
// Wanted here too: the device's copy follows once iPX has the file (#457).
|
||||||
|
if(a==='want'){ want(enc.id, entryName(e).text); toast('Downloading: '+entryName(e).text+'. It comes to this device once iPX has it.'); }
|
||||||
|
else toast('Queued: '+entryName(e).text);
|
||||||
|
redraw();
|
||||||
}
|
}
|
||||||
if(a==='del'){
|
if(a==='del'){
|
||||||
const f=S.feeds.find(x=>x.id===e.feed_id);
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
|
|||||||
@@ -46,24 +46,41 @@ if(worker) (async()=>{
|
|||||||
try{ for(const k of await (await caches.open(KEPT)).keys()) kept.add(new URL(k.url).pathname); }catch{}
|
try{ for(const k of await (await caches.open(KEPT)).keys()) kept.add(new URL(k.url).pathname); }catch{}
|
||||||
if(kept.size) syncKeepButtons();
|
if(kept.size) syncKeepButtons();
|
||||||
})();
|
})();
|
||||||
|
/// The open item's files say where each is, and its button what it does next (#457): drawn again
|
||||||
|
/// in place, keeping the reader where it was.
|
||||||
function syncKeepButtons(){
|
function syncKeepButtons(){
|
||||||
document.querySelectorAll<HTMLButtonElement>('[data-a="offline"]').forEach(b=>{
|
const e=cur();
|
||||||
const k=kept.has(b.dataset.url!);
|
if(e && $('#files .encbox, #detail .encbox')){
|
||||||
b.setAttribute('aria-pressed',String(k));
|
const box=$('#detail'), top=box?.scrollTop??0;
|
||||||
b.setAttribute('aria-busy',String(keeping.has(b.dataset.url!)));
|
showDetail(e);
|
||||||
b.innerHTML=k?ICON.kept:ICON.keep;
|
if(box) box.scrollTop=top;
|
||||||
const label=k?'Kept on this device to play offline. Remove it':'Keep on this device to play offline';
|
}
|
||||||
b.title=label; b.setAttribute('aria-label',label);
|
// A row says whether its item is kept; redrawn only where that has changed.
|
||||||
}); // A row says whether its item is kept; redrawn only where that has changed.
|
|
||||||
for(const e of S.entries)
|
for(const e of S.entries)
|
||||||
if(e.enclosures.some(isKept)!==!!$(`#eps .ep[data-key="${CSS.escape(entryKey(e))}"] .kept`)) swapRow(e);
|
if(e.enclosures.some(isKept)!==!!$(`#eps .ep[data-key="${CSS.escape(entryKey(e))}"] .kept`)) swapRow(e);
|
||||||
}
|
}
|
||||||
/// The button for a file on the server that plays; none without a worker, or an app, to play it
|
/// Whether this device can keep a file to play offline: a worker to keep it, or the app.
|
||||||
/// offline.
|
const canKeep=()=>!!worker||!!ipxHost;
|
||||||
function keepBtn(x: Enclosure){
|
/* Files asked for on this device before iPX had them (#457): iPX fetches each, and its copy here
|
||||||
if(!worker&&!ipxHost) return '';
|
follows once iPX says it is done, or, when the page was closed meanwhile, the next time a list
|
||||||
const k=isKept(x), label=k?'Kept on this device to play offline. Remove it':'Keep on this device to play offline';
|
shows it with its file. Kept in localStorage, so a closed app still remembers. */
|
||||||
return `<button class="btn ico" data-a="offline" data-enc="${x.id}" data-url="${keepUrl(x)}" aria-pressed="${k}" aria-busy="${keeping.has(keepUrl(x))}" title="${label}" aria-label="${label}">${k?ICON.kept:ICON.keep}</button>`;
|
const wanted=new Map<number,string>();
|
||||||
|
try{ for(const [id,name] of JSON.parse(localStorage.getItem('ipx.wanted')||'[]') as [number,string][]) wanted.set(id,name); }catch{}
|
||||||
|
function saveWanted(){ try{ localStorage.setItem('ipx.wanted',JSON.stringify([...wanted])); }catch{} }
|
||||||
|
function want(id: number, name: string){ wanted.set(id,name); saveWanted(); }
|
||||||
|
function unwant(id: number){ if(wanted.delete(id)) saveWanted(); }
|
||||||
|
/// iPX has the file now: this device's copy follows. The copy without dead air comes later, if it
|
||||||
|
/// does, so the file itself is what is kept.
|
||||||
|
function followWanted(x: {id: number, path?: string|null, deadair?: number|null}){
|
||||||
|
const name=wanted.get(x.id);
|
||||||
|
if(name==null || !x.path) return;
|
||||||
|
unwant(x.id);
|
||||||
|
if(!isKept(x)) void toggleKept(x as Enclosure, name);
|
||||||
|
}
|
||||||
|
/// Those of `entries` iPX fetched while this page was not looking.
|
||||||
|
function followWantedIn(entries: Entry[]){
|
||||||
|
if(!wanted.size) return;
|
||||||
|
for(const e of entries) for(const x of e.enclosures) if(x.path && wanted.has(x.id)) followWanted(x);
|
||||||
}
|
}
|
||||||
async function toggleKept(x: Enclosure, name: string){
|
async function toggleKept(x: Enclosure, name: string){
|
||||||
const url=keepUrl(x);
|
const url=keepUrl(x);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const fa=(box: string,body: string)=>`<svg class="i" viewBox="${box}" aria-hidde
|
|||||||
/// An app-icon tile (web/signals.svg, deadair-on.svg, ...) in the mode's own variant, as the logo is.
|
/// An app-icon tile (web/signals.svg, deadair-on.svg, ...) in the mode's own variant, as the logo is.
|
||||||
const tile=(name: string)=>`<img class="tile onlight" src="/${name}.svg" alt=""><img class="tile ondark" src="/${name}-dark.svg" alt="">`;
|
const tile=(name: string)=>`<img class="tile onlight" src="/${name}.svg" alt=""><img class="tile ondark" src="/${name}-dark.svg" alt="">`;
|
||||||
const ICON: Record<string,string>={
|
const ICON: Record<string,string>={
|
||||||
|
more:fa('0 0 448 512','<path fill="currentColor" d="M8 256a56 56 0 1 1 112 0A56 56 0 1 1 8 256zm160 0a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm216-56a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/>'), // solid/ellipsis, for a file's other actions (#457)
|
||||||
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
|
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
|
||||||
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
||||||
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
||||||
|
|||||||
Reference in New Issue
Block a user