diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js
index d7dfc14..6f137c8 100644
--- a/frontend/app-seams.test.js
+++ b/frontend/app-seams.test.js
@@ -57,3 +57,14 @@ test('watch-party facade keeps playback decisions synchronous before and after l
context.window.YT={Party:{guestActive:()=>true,intercept:action=>action==='next',nextDJ:()=>true}};
for(const method of ['guestActive','intercept','nextDJ'])assert.equal(context.api[method]('next'),true);
});
+
+test('Share and External stay behind lazy entry proxies',()=>{
+ assert.match(app,/const Share = Lazy\.proxy\('feature:share-external'/);
+ assert.match(app,/const External = Lazy\.proxy\('feature:share-external'/);
+ const chunk=readFileSync(join(__dirname,'share-external.js'),'utf8');
+ const context=require('node:vm').createContext({window:{},navigator:{userAgent:''},location:{origin:'https://fixture.test'},isUploadId:()=>false});
+ require('node:vm').runInContext(chunk,context);
+ assert.equal(typeof context.window.YT.Share.open,'function');
+ const text=context.window.YT.External.m3u([{id:'benchmedia01',title:'Fixture'}]);
+ assert.match(text,/#EXTM3U/);assert.match(text,/benchmedia01/);
+});
diff --git a/frontend/app.js b/frontend/app.js
index 3706557..a7be822 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -5349,152 +5349,8 @@ async function playVideoAt(v, t = 0) {
// ============================================================================
// Timestamp sharing — deep links (/?v=ID&t=SEC) and GIFs of a moment
// ============================================================================
-const Share = (() => {
- const appLink = (id, t) => `${location.origin}/?v=${encodeURIComponent(id)}${t >= 1 ? `&t=${Math.floor(t)}` : ''}`;
- const ytLink = (id, t) => `https://youtu.be/${encodeURIComponent(id)}${t >= 1 ? `?t=${Math.floor(t)}` : ''}`;
+const Share = Lazy.proxy('feature:share-external', ['open', 'bootFromUrl'], 'Share');
- async function copy(text, label) {
- try { await navigator.clipboard.writeText(text); toast(`${label} copied ✓`); }
- catch { toast('Copy blocked — long-press the link to copy it'); }
- }
-
- function open() {
- const meta = current && current.meta;
- if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; }
- const t0 = Math.floor(Player.master.currentTime || 0);
- const body = document.createElement('div');
- body.className = 'share-moment';
- body.innerHTML = `
- Moment
-
-
This app Copy
- ${isUploadId(meta.id) ? '' : '
YouTube Copy
'}
-
-
-
- GIF length 2 s 3 s 5 s
- 🎞 Make GIF
-
-
-
- `;
- const timeIn = body.querySelector('.sm-time');
- const cur = () => { const v = LyricsCore.parseStamp(timeIn.value); return v === null ? t0 : v; };
- const refresh = () => {
- body.querySelector('.sm-app').textContent = appLink(meta.id, cur());
- const yt = body.querySelector('.sm-yt');
- if (yt) yt.textContent = ytLink(meta.id, cur());
- };
- timeIn.addEventListener('input', refresh);
- timeIn.addEventListener('keydown', (e) => e.stopPropagation());
- refresh();
- // Soundbite range: the A-B loop when one is set, else the next 20 s.
- const bStart = abA !== null ? abA : t0;
- const bEnd = abB !== null && abB > bStart ? abB : bStart + 20;
- body.querySelector('.sm-b-start').value = LyricsCore.fmtClock(bStart);
- body.querySelector('.sm-b-end').value = LyricsCore.fmtClock(bEnd);
- body.querySelectorAll('.sm-bite input').forEach((i) => i.addEventListener('keydown', (e) => e.stopPropagation()));
- let biteBlob = null;
- let gifBlob = null;
- body.addEventListener('click', async (e) => {
- const act = e.target.closest('[data-act]');
- if (!act) return;
- if (act.dataset.act === 'app') copy(appLink(meta.id, cur()), 'App link');
- else if (act.dataset.act === 'yt') copy(ytLink(meta.id, cur()), 'YouTube link');
- else if (act.dataset.act === 'gif') {
- const out = body.querySelector('.sm-gif-out');
- out.innerHTML = 'Making the GIF…
';
- act.disabled = true;
- try {
- const d = body.querySelector('.sm-len').value;
- const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/gif?t=${cur()}&d=${d}&w=480`);
- if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); }
- gifBlob = await res.blob();
- const url = URL.createObjectURL(gifBlob);
- const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(cur())}s.gif`;
- out.innerHTML = `
- ⤓ Download
- ${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/gif' })] }) ? '
↗ Share GIF ' : ''}
`;
- out.dataset.name = name;
- } catch (err) {
- out.innerHTML = `⚠ ${escapeHtml(err.message)}
`;
- } finally {
- act.disabled = false;
- }
- } else if (act.dataset.act === 'bite') {
- const out = body.querySelector('.sm-bite-out');
- const a = LyricsCore.parseStamp(body.querySelector('.sm-b-start').value);
- const b = LyricsCore.parseStamp(body.querySelector('.sm-b-end').value);
- const fmt = body.querySelector('.sm-b-fmt').value;
- const max = fmt === 'm4r' ? 40 : 60;
- if (a === null || b === null || b <= a) { out.innerHTML = '⚠ Times must look like 1:23, end after start
'; return; }
- if (b - a > max) { out.innerHTML = `⚠ Max ${max} s for ${fmt === 'm4r' ? 'an iPhone ringtone' : 'a soundbite'}
`; return; }
- out.innerHTML = 'Cutting the soundbite…
';
- act.disabled = true;
- try {
- const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/clip?start=${a}&end=${b}&fmt=${fmt}`);
- if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); }
- biteBlob = await res.blob();
- const url = URL.createObjectURL(biteBlob);
- const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(a)}s.${fmt}`;
- out.dataset.name = name;
- out.dataset.type = fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
- out.innerHTML = `
- ⤓ Download
- ${navigator.canShare && navigator.canShare({ files: [new File([biteBlob], name, { type: out.dataset.type })] }) ? '
↗ Share ' : ''}
- ${fmt === 'm4r' ? 'iPhone: share it to GarageBand (or drag into Finder with the phone connected) to set it as a ringtone.
' : ''}`;
- } catch (err) {
- out.innerHTML = `⚠ ${escapeHtml(err.message)}
`;
- } finally {
- act.disabled = false;
- }
- } else if (act.dataset.act === 'sharebite' && biteBlob) {
- const o = body.querySelector('.sm-bite-out');
- try { await navigator.share({ files: [new File([biteBlob], o.dataset.name, { type: o.dataset.type })], title: meta.title }); } catch { /* dismissed */ }
- } else if (act.dataset.act === 'sharegif' && gifBlob) {
- const name = body.querySelector('.sm-gif-out').dataset.name;
- try { await navigator.share({ files: [new File([gifBlob], name, { type: 'image/gif' })], title: meta.title }); } catch { /* dismissed */ }
- }
- });
- const actions = [{ label: 'Done', onClick: closeModal }];
- if (navigator.share) {
- actions.push({ label: '↗ Share link', primary: true, onClick: async () => {
- try { await navigator.share({ title: meta.title, text: `${meta.title} @ ${LyricsCore.fmtClock(cur())}`, url: appLink(meta.id, cur()) }); } catch { /* dismissed */ }
- } });
- } else {
- actions.push({ label: 'Copy app link', primary: true, onClick: () => copy(appLink(meta.id, cur()), 'App link') });
- }
- showModal('🔗 Share this moment', body, actions);
- }
-
- // /?v=&t= — open that video at that moment.
- async function bootFromUrl() {
- let id = '', t = 0;
- try {
- const q = new URLSearchParams(location.search);
- id = (q.get('v') || '').trim();
- t = Math.max(0, parseFloat(q.get('t')) || 0);
- } catch { return; }
- if (!isMediaId(id)) return;
- try {
- const u = new URL(location.href);
- u.searchParams.delete('v');
- u.searchParams.delete('t');
- history.replaceState(null, '', u.pathname + u.search + u.hash);
- } catch { /* keep */ }
- await playVideoAt(data.history.find((x) => x.id === id) || { id, title: '' }, t);
- }
-
- return { open, bootFromUrl };
-})();
// ============================================================================
// Loudness levelling (EBU R128) — every track toward -14 LUFS
@@ -6087,78 +5943,8 @@ const Gestures = (() => {
* ========================================================================== */
-const External = (() => {
- const isAndroid = /Android/i.test(navigator.userAgent);
- const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
- const streamUrl = (id) => `${location.origin}${isUploadId(id) ? '/api/uploads/' : '/api/media/'}${encodeURIComponent(id)}`;
- async function cached(id) {
- if (isUploadId(id)) return true; // uploads are always a single file on the server
- try { const j = await (await fetch(`/api/media/${encodeURIComponent(id)}/status`)).json(); return j && j.status === 'ready'; } catch { return false; }
- }
- const intent = (url, title, pkg) => `intent:${url}#Intent;action=android.intent.action.VIEW;type=video/*;${pkg ? `package=${pkg};` : ''}S.title=${encodeURIComponent(title || '')};end`;
- function m3u(items) {
- return ['#EXTM3U', ...items.flatMap((v) => [`#EXTINF:${Math.round(v.duration || -1)},${(v.title || v.id).replace(/[\r\n]/g, ' ')}`, streamUrl(v.id)])].join('\n') + '\n';
- }
- function download(name, text) {
- const a = document.createElement('a');
- a.href = URL.createObjectURL(new Blob([text], { type: 'audio/x-mpegurl' }));
- a.download = name;
- a.click();
- setTimeout(() => URL.revokeObjectURL(a.href), 2000);
- }
+const External = Lazy.proxy('feature:share-external', ['open', 'm3u', 'intent'], 'External');
- async function open() {
- const meta = current && current.meta;
- if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; }
- const body = document.createElement('div');
- body.className = 'ext-panel';
- body.innerHTML = 'Checking the server copy…
';
- showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
- const ok = await cached(meta.id);
- if (!ok) {
- body.innerHTML = `The server hasn't finished caching this video yet, and other players need that single-file copy. Keep it playing for a minute (or tap ⬇ Save), then try again.
`;
- return;
- }
- const url = streamUrl(meta.id);
- const title = meta.title || meta.id;
- const inList = queue.length > 1 && queueIndex >= 0;
- body.innerHTML = `
- Stream ${escapeHtml(url)}Copy
-
- ${isAndroid ? 'VLC / MX Player must be installed; “Open with…” shows every video app.'
- : isIOS ? 'Needs the VLC (or Infuse) app. The link streams from your server; nothing is downloaded first.'
- : 'Desktop VLC may not register vlc:// links — the .m3u file opens in VLC, mpv, IINA or any media player.'}
`;
- body.addEventListener('click', async (e) => {
- const act = e.target.closest('[data-act]');
- if (!act) return;
- if (act.dataset.act === 'copy') {
- try { await navigator.clipboard.writeText(url); toast('Stream URL copied ✓'); } catch { toast('Copy blocked'); }
- } else if (act.dataset.act === 'm3u') {
- download(`${title.replace(/[^\w\- ]+/g, '').trim().slice(0, 50) || meta.id}.m3u`, m3u([meta]));
- } else if (act.dataset.act === 'm3u-list') {
- act.disabled = true;
- const items = queue.slice(Math.max(0, queueIndex));
- const flags = await Promise.all(items.map((v) => (v.custom ? false : cached(v.id))));
- const ready = items.filter((_, i) => flags[i]);
- download('ytplayer-queue.m3u', m3u(ready));
- toast(`Playlist with ${ready.length} of ${items.length} songs${ready.length < items.length ? ' (the rest aren’t cached on the server yet)' : ''}`, { duration: 5000 });
- act.disabled = false;
- }
- });
- }
- return { open, m3u, intent };
-})();
// ============================================================================
// 🎉 Watch party — synced playback + chat + voice (server/party.js relays)
@@ -9341,7 +9127,7 @@ async function boot() {
await Presenter.boot();
if (LowerThird.active()) return;
SectionRail.boot();
- Share.bootFromUrl();
+ if (isMediaId(new URLSearchParams(location.search).get('v'))) Share.bootFromUrl().catch(error => toast(error.message));
bootViewFromUrl();
bootSearchFromUrl();
if (WEB) bootShareFromUrl();
@@ -9433,6 +9219,7 @@ async function boot() {
window.addEventListener('ytp-lazy-error',event=>{ toast(event.detail?.error?.message||'Feature unavailable; try Refresh UI'); checkBuildTag(); });
Lazy.intent($('navPartyBtn'),'feature:party-dj'); Lazy.intent($('navRemoteBtn'),'feature:direct');
Lazy.intent($('remoteChip'),'feature:direct'); Lazy.intent($('eqBtn'),'feature:eq-core');
+ Lazy.intent($('shareMomentBtn'),'feature:share-external'); Lazy.intent($('externalBtn'),'feature:share-external');
Lazy.reportLayout?.(); Lazy.warm?.();
if (WEB) {
fetch('/api/user/sync', {
diff --git a/frontend/assets.json b/frontend/assets.json
index ef30f6f..d81c7be 100644
--- a/frontend/assets.json
+++ b/frontend/assets.json
@@ -340,6 +340,14 @@
"files": [
"/watch-party-client.js"
]
+ },
+ "feature:share-external": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": [
+ "/share-external.js"
+ ]
}
}
}
diff --git a/frontend/index.html b/frontend/index.html
index e288275..6230dec 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -19,7 +19,7 @@
-
+
diff --git a/frontend/share-external.js b/frontend/share-external.js
new file mode 100644
index 0000000..cf52263
--- /dev/null
+++ b/frontend/share-external.js
@@ -0,0 +1,221 @@
+/* Lazy classic chunk; its executing singleton stays pinned until reload. */
+(window.YT ||= {}).Share = (() => {
+ const appLink = (id, t) => `${location.origin}/?v=${encodeURIComponent(id)}${t >= 1 ? `&t=${Math.floor(t)}` : ''}`;
+ const ytLink = (id, t) => `https://youtu.be/${encodeURIComponent(id)}${t >= 1 ? `?t=${Math.floor(t)}` : ''}`;
+
+ async function copy(text, label) {
+ try { await navigator.clipboard.writeText(text); toast(`${label} copied ✓`); }
+ catch { toast('Copy blocked — long-press the link to copy it'); }
+ }
+
+ function open() {
+ const meta = current && current.meta;
+ if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; }
+ const t0 = Math.floor(Player.master.currentTime || 0);
+ const body = document.createElement('div');
+ body.className = 'share-moment';
+ body.innerHTML = `
+ Moment
+
+
This app Copy
+ ${isUploadId(meta.id) ? '' : '
YouTube Copy
'}
+
+
+
+ GIF length 2 s 3 s 5 s
+ 🎞 Make GIF
+
+
+
+ `;
+ const timeIn = body.querySelector('.sm-time');
+ const cur = () => { const v = LyricsCore.parseStamp(timeIn.value); return v === null ? t0 : v; };
+ const refresh = () => {
+ body.querySelector('.sm-app').textContent = appLink(meta.id, cur());
+ const yt = body.querySelector('.sm-yt');
+ if (yt) yt.textContent = ytLink(meta.id, cur());
+ };
+ timeIn.addEventListener('input', refresh);
+ timeIn.addEventListener('keydown', (e) => e.stopPropagation());
+ refresh();
+ // Soundbite range: the A-B loop when one is set, else the next 20 s.
+ const bStart = abA !== null ? abA : t0;
+ const bEnd = abB !== null && abB > bStart ? abB : bStart + 20;
+ body.querySelector('.sm-b-start').value = LyricsCore.fmtClock(bStart);
+ body.querySelector('.sm-b-end').value = LyricsCore.fmtClock(bEnd);
+ body.querySelectorAll('.sm-bite input').forEach((i) => i.addEventListener('keydown', (e) => e.stopPropagation()));
+ let biteBlob = null;
+ let gifBlob = null;
+ body.addEventListener('click', async (e) => {
+ const act = e.target.closest('[data-act]');
+ if (!act) return;
+ if (act.dataset.act === 'app') copy(appLink(meta.id, cur()), 'App link');
+ else if (act.dataset.act === 'yt') copy(ytLink(meta.id, cur()), 'YouTube link');
+ else if (act.dataset.act === 'gif') {
+ const out = body.querySelector('.sm-gif-out');
+ out.innerHTML = 'Making the GIF…
';
+ act.disabled = true;
+ try {
+ const d = body.querySelector('.sm-len').value;
+ const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/gif?t=${cur()}&d=${d}&w=480`);
+ if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); }
+ gifBlob = await res.blob();
+ const url = URL.createObjectURL(gifBlob);
+ const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(cur())}s.gif`;
+ out.innerHTML = `
+ ⤓ Download
+ ${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/gif' })] }) ? '
↗ Share GIF ' : ''}
`;
+ out.dataset.name = name;
+ } catch (err) {
+ out.innerHTML = `⚠ ${escapeHtml(err.message)}
`;
+ } finally {
+ act.disabled = false;
+ }
+ } else if (act.dataset.act === 'bite') {
+ const out = body.querySelector('.sm-bite-out');
+ const a = LyricsCore.parseStamp(body.querySelector('.sm-b-start').value);
+ const b = LyricsCore.parseStamp(body.querySelector('.sm-b-end').value);
+ const fmt = body.querySelector('.sm-b-fmt').value;
+ const max = fmt === 'm4r' ? 40 : 60;
+ if (a === null || b === null || b <= a) { out.innerHTML = '⚠ Times must look like 1:23, end after start
'; return; }
+ if (b - a > max) { out.innerHTML = `⚠ Max ${max} s for ${fmt === 'm4r' ? 'an iPhone ringtone' : 'a soundbite'}
`; return; }
+ out.innerHTML = 'Cutting the soundbite…
';
+ act.disabled = true;
+ try {
+ const res = await fetch(`/api/media/${encodeURIComponent(meta.id)}/clip?start=${a}&end=${b}&fmt=${fmt}`);
+ if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); }
+ biteBlob = await res.blob();
+ const url = URL.createObjectURL(biteBlob);
+ const name = `${(meta.title || meta.id).replace(/[^\w\- ]+/g, '').trim().slice(0, 40) || meta.id}-${Math.floor(a)}s.${fmt}`;
+ out.dataset.name = name;
+ out.dataset.type = fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
+ out.innerHTML = `
+ ⤓ Download
+ ${navigator.canShare && navigator.canShare({ files: [new File([biteBlob], name, { type: out.dataset.type })] }) ? '
↗ Share ' : ''}
+ ${fmt === 'm4r' ? 'iPhone: share it to GarageBand (or drag into Finder with the phone connected) to set it as a ringtone.
' : ''}`;
+ } catch (err) {
+ out.innerHTML = `⚠ ${escapeHtml(err.message)}
`;
+ } finally {
+ act.disabled = false;
+ }
+ } else if (act.dataset.act === 'sharebite' && biteBlob) {
+ const o = body.querySelector('.sm-bite-out');
+ try { await navigator.share({ files: [new File([biteBlob], o.dataset.name, { type: o.dataset.type })], title: meta.title }); } catch { /* dismissed */ }
+ } else if (act.dataset.act === 'sharegif' && gifBlob) {
+ const name = body.querySelector('.sm-gif-out').dataset.name;
+ try { await navigator.share({ files: [new File([gifBlob], name, { type: 'image/gif' })], title: meta.title }); } catch { /* dismissed */ }
+ }
+ });
+ const actions = [{ label: 'Done', onClick: closeModal }];
+ if (navigator.share) {
+ actions.push({ label: '↗ Share link', primary: true, onClick: async () => {
+ try { await navigator.share({ title: meta.title, text: `${meta.title} @ ${LyricsCore.fmtClock(cur())}`, url: appLink(meta.id, cur()) }); } catch { /* dismissed */ }
+ } });
+ } else {
+ actions.push({ label: 'Copy app link', primary: true, onClick: () => copy(appLink(meta.id, cur()), 'App link') });
+ }
+ showModal('🔗 Share this moment', body, actions);
+ }
+
+ // /?v=&t= — open that video at that moment.
+ async function bootFromUrl() {
+ let id = '', t = 0;
+ try {
+ const q = new URLSearchParams(location.search);
+ id = (q.get('v') || '').trim();
+ t = Math.max(0, parseFloat(q.get('t')) || 0);
+ } catch { return; }
+ if (!isMediaId(id)) return;
+ try {
+ const u = new URL(location.href);
+ u.searchParams.delete('v');
+ u.searchParams.delete('t');
+ history.replaceState(null, '', u.pathname + u.search + u.hash);
+ } catch { /* keep */ }
+ await playVideoAt(data.history.find((x) => x.id === id) || { id, title: '' }, t);
+ }
+
+ return { open, bootFromUrl };
+})();
+
+/* Lazy classic chunk; its executing singleton stays pinned until reload. */
+(window.YT ||= {}).External = (() => {
+ const isAndroid = /Android/i.test(navigator.userAgent);
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
+ const streamUrl = (id) => `${location.origin}${isUploadId(id) ? '/api/uploads/' : '/api/media/'}${encodeURIComponent(id)}`;
+ async function cached(id) {
+ if (isUploadId(id)) return true; // uploads are always a single file on the server
+ try { const j = await (await fetch(`/api/media/${encodeURIComponent(id)}/status`)).json(); return j && j.status === 'ready'; } catch { return false; }
+ }
+ const intent = (url, title, pkg) => `intent:${url}#Intent;action=android.intent.action.VIEW;type=video/*;${pkg ? `package=${pkg};` : ''}S.title=${encodeURIComponent(title || '')};end`;
+ function m3u(items) {
+ return ['#EXTM3U', ...items.flatMap((v) => [`#EXTINF:${Math.round(v.duration || -1)},${(v.title || v.id).replace(/[\r\n]/g, ' ')}`, streamUrl(v.id)])].join('\n') + '\n';
+ }
+ function download(name, text) {
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(new Blob([text], { type: 'audio/x-mpegurl' }));
+ a.download = name;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(a.href), 2000);
+ }
+
+ async function open() {
+ const meta = current && current.meta;
+ if (!meta || !meta.id || meta.custom) { toast('Play a YouTube video first'); return; }
+ const body = document.createElement('div');
+ body.className = 'ext-panel';
+ body.innerHTML = 'Checking the server copy…
';
+ showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
+ const ok = await cached(meta.id);
+ if (!ok) {
+ body.innerHTML = `The server hasn't finished caching this video yet, and other players need that single-file copy. Keep it playing for a minute (or tap ⬇ Save), then try again.
`;
+ return;
+ }
+ const url = streamUrl(meta.id);
+ const title = meta.title || meta.id;
+ const inList = queue.length > 1 && queueIndex >= 0;
+ body.innerHTML = `
+ Stream ${escapeHtml(url)}Copy
+
+ ${isAndroid ? 'VLC / MX Player must be installed; “Open with…” shows every video app.'
+ : isIOS ? 'Needs the VLC (or Infuse) app. The link streams from your server; nothing is downloaded first.'
+ : 'Desktop VLC may not register vlc:// links — the .m3u file opens in VLC, mpv, IINA or any media player.'}
`;
+ body.addEventListener('click', async (e) => {
+ const act = e.target.closest('[data-act]');
+ if (!act) return;
+ if (act.dataset.act === 'copy') {
+ try { await navigator.clipboard.writeText(url); toast('Stream URL copied ✓'); } catch { toast('Copy blocked'); }
+ } else if (act.dataset.act === 'm3u') {
+ download(`${title.replace(/[^\w\- ]+/g, '').trim().slice(0, 50) || meta.id}.m3u`, m3u([meta]));
+ } else if (act.dataset.act === 'm3u-list') {
+ act.disabled = true;
+ const items = queue.slice(Math.max(0, queueIndex));
+ const flags = await Promise.all(items.map((v) => (v.custom ? false : cached(v.id))));
+ const ready = items.filter((_, i) => flags[i]);
+ download('ytplayer-queue.m3u', m3u(ready));
+ toast(`Playlist with ${ready.length} of ${items.length} songs${ready.length < items.length ? ' (the rest aren’t cached on the server yet)' : ''}`, { duration: 5000 });
+ act.disabled = false;
+ }
+ });
+ }
+ return { open, m3u, intent };
+})();
diff --git a/frontend/sw.js b/frontend/sw.js
index 74c2329..a1a84d2 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -143,6 +143,7 @@ const SHELL = [
'/presenter.js',
'/remote-client.js',
'/watch-party-client.js',
+ '/share-external.js',
'/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',
diff --git a/perf/seams.mjs b/perf/seams.mjs
index e661828..1413091 100644
--- a/perf/seams.mjs
+++ b/perf/seams.mjs
@@ -54,7 +54,9 @@ async function run(name){
}
if(groups.includes('feature:share-external')) {
await page.evaluate(async()=>{current={meta:{id:'benchmedia01',title:'Cached smoke',channel:'Fixture'}};await Share.open();});
- await page.waitForSelector('.share-moment');assert.match(await page.locator('#modal').textContent(),/Cached smoke/);
+ await page.waitForSelector('.share-moment');assert.match(await page.locator('.sm-app').textContent(),/v=benchmedia01/);
+ await page.locator('.sm-time').fill('0:30');await page.locator('.sm-time').dispatchEvent('input');
+ assert.match(await page.locator('.sm-app').textContent(),/t=30/);
const m3u=await page.evaluate(async()=>{await Lazy.load('feature:share-external');return window.YT.External.m3u([{id:'benchmedia01',title:'Cached smoke'}]);});assert.match(m3u,/#EXTM3U/);assert.match(m3u,/benchmedia01/);
await page.evaluate(()=>closeModal());checked.push('share');
}