Share an episode as a page, on ipx-play (#447)

Share handed over the raw file address or the item's link: no preview,
no time, nothing a person without iPX could do much with. Now, as
Overcast does, an episode shares as a short link to its own page,
play.ipxreader.app/e/<id>, which anyone can open: the show's art and
name, the episode's title, date, length and notes, a player on the
publisher's own file starting at ?t= if a time was shared, buttons to
subscribe in Apple Podcasts, Overcast, Pocket Casts or Castro, and Open
Graph tags for a preview where the link is pasted.

ipx-play is a service of its own (crates/play), built like the
directory: ipx sends it the episode with its token (POST /v1/shares)
and gets the link back. The id comes from the feed's address and the
guid, so the same episode is one link whoever shares it. Only episodes
of feeds the directory would be sent go there, by the directory's own
rule (snapshot_entries), so the two cannot disagree on what is private;
ipx-play also refuses an address that looks private. Anything else, and
any server without ipx-play set up, shares as before.

In the page, Share on an episode opens a menu once the link is back:
Share link, Share from 12:34 (the player's clock, or where you left
off), Copy link. A menu because the share sheet needs a press of its
own, and the request for the link used up the one that opened it.

The page runs no script: the start time is the media fragment #t=, and
its CSP loads nothing but its stylesheet, the art and the file.

Admin, Directory, Sharing episodes sets the address and token. The
browser suite starts its own ipx-play; it was not run here (no Caddy on
this Mac).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-13 09:24:18 -05:00
parent 171c77475a
commit 679a674fc9
32 changed files with 1595 additions and 24 deletions

View File

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Share on an episode offers Share link, Share from where you are, and Copy link, with a link to the episode's page on ipx-play that anyone can play; an admin sets ipx-play up in Admin, Directory (#447).
- In the iOS app, Settings has a This device section for the app's server and player token, with a button that makes a token and hands it to the app (#445).
- In the iOS app, the page registers the phone with ipx for its icon's count, and signing out removes it (#433).
- In the iOS app, the player bar shows what CarPlay plays, so it can be paused on the phone after leaving the car, including when the app was opened by the car (#420, #442).

View File

@@ -1182,6 +1182,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.inline input[type=number]{flex:none;width:90px}
.check input{width:16px;height:16px;accent-color:var(--accent)}
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
/* Share an episode (#447): one wide button a choice, easy to hit on a phone. */
.sharemenu{display:flex;flex-direction:column;gap:8px;margin:14px 0}
.sharemenu .btn{justify-content:center}
/* ---------- a feed's settings (#167) ---------- */
/* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been

View File

@@ -107,6 +107,17 @@ async function drawDirectorySettings(){
<p class="cap">Every half hour, the feeds people here subscribe to and how many do. Never a
private feed: one marked private, one with a login, or one whose address carries a key.</p>
<div class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>`)}
${asect('Sharing episodes', `<p class="cap">Share gives a link to the episode's own page on
ipx-play, which anyone can open and play, as Overcast's links do. Only episodes of public
feeds; without it, Share hands over the episode's own address.</p>
<div class="ffield"><label class="flabel" for="purl">ipx-play address</label>
<input type="text" inputmode="url" id="purl" value="${esc(g.play_url)}" placeholder="https://play.ipxreader.app" spellcheck="false"></div>
<div class="ffield"><label class="flabel" for="ptoken">Access token</label>
<div class="inline"><input type="password" id="ptoken" autocomplete="off" spellcheck="false"
placeholder="${g.play_token_set ? 'Set. Paste a new one to replace it' : 'Paste the token ipx-play gave this server'}">
${g.play_token_set ? `<button type="button" class="btn" id="pclear">Clear</button>` : ''}</div>
<p class="cap">Whoever runs ipx-play makes one with <code>ipx-play token &lt;name&gt;</code>.</p></div>
<div class="cardacts"><button class="btn primary" id="psave">${ICON.check} Save</button></div>`)}
<div id="dadmin"></div>`;
const save = async (patch: Record<string, unknown>, said: string) => {
try{
@@ -123,6 +134,13 @@ async function drawDirectorySettings(){
};
const clear = $<HTMLButtonElement>('#dclear');
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
$<HTMLButtonElement>('#psave').onclick = () => {
const patch: Record<string, unknown> = {play_url: $<HTMLInputElement>('#purl').value.trim()};
if($<HTMLInputElement>('#ptoken').value.trim()) patch.play_token = $<HTMLInputElement>('#ptoken').value.trim();
save(patch, 'Sharing settings saved');
};
const pclear = $<HTMLButtonElement>('#pclear');
if(pclear) pclear.onclick = () => save({play_token: ''}, 'Token cleared');
drawDirectoryStatus();
}

View File

@@ -518,6 +518,44 @@ function encBox(x: Enclosure){
</div>`;
}
/// A shared link that starts at a time (#447): ipx-play's page reads `?t=` in seconds. Under five
/// seconds in is the start.
const shareAt=(url: string, secs: number)=>secs>=5 ? `${url}?t=${Math.floor(secs)}` : url;
/// An episode shared as Overcast shares one (#447): a link to its page on ipx-play, where anyone
/// can play it, from where you are in it if you like. A private feed, a post, or a server with no
/// ipx-play shares the item's own address, as before.
async function shareEpisode(e: Entry, encId: number|null, btn?: HTMLElement){
const name=entryName(e).text;
const own=encId!=null ? e.enclosures.find(x=>x.id===encId)?.url : e.link;
let link: {url: string|null, why: string|null}={url:null, why:null};
try{
link=await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/share`,
{method:'POST', body:JSON.stringify(encId!=null ? {enc:encId} : {})});
}catch{}
if(!link.url){
if(own) await share(name, own, btn); else toast('There is nothing to share for this item.', true);
return;
}
const url=link.url;
// Where you are: the player's clock when it has this one, else where you left off.
const at=player.guid===e.guid && player.feed===e.feed_id ? audio.currentTime : e.position;
// A menu rather than the share sheet at once: the sheet needs a press of its own, and the
// request above has used up the one that opened this.
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
<h3>Share</h3><p class="hint">${esc(name)}</p>
<div class="sharemenu">
<button class="btn primary" id="shlink">${ICON.share} Share link</button>
${at>=5?`<button class="btn" id="shat">${ICON.share} Share from ${clock(at)}</button>`:''}
<button class="btn" id="shcopy">${ICON.copy} Copy link</button>
</div>
<p class="hint">Anyone with the link can play it, and subscribe in their own podcast app.</p>`);
$<HTMLButtonElement>('#shlink').onclick=()=>{ closeModal(); share(name, url); };
const timed=$<HTMLButtonElement>('#shat');
if(timed) timed.onclick=()=>{ closeModal(); share(name, shareAt(url, at)); };
$<HTMLButtonElement>('#shcopy').onclick=async()=>{ await copyText(url, $<HTMLButtonElement>('#shcopy')); toast('Link copied'); closeModal(); };
}
async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: number){
// Swap the row in place and refresh the text below when it is the one being read.
const redraw=()=>{ swapRow(e); if(!el || S.sel===entryKey(e)) showDetail(e); };
@@ -526,7 +564,7 @@ async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: numbe
try{
if(a==='play'){ haptic('tap'); play(e, encId!=null ? enc : undefined); }
if(a==='offline' && enc) await toggleKept(enc, entryName(e).text);
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link!, el??undefined);
if(a==='share') await shareEpisode(e, encId??null, el??undefined);
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==='get'){