Load sharing and external-player tools on demand
This commit is contained in:
@@ -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}};
|
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);
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
221
frontend/app.js
221
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
|
// Timestamp sharing — deep links (/?v=ID&t=SEC) and GIFs of a moment
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
const Share = (() => {
|
const Share = Lazy.proxy('feature:share-external', ['open', 'bootFromUrl'], '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 = `
|
|
||||||
<label class="sm-row">Moment <input class="sm-time" inputmode="decimal" value="${LyricsCore.fmtClock(t0)}" /></label>
|
|
||||||
<div class="sm-links">
|
|
||||||
<div class="sm-link"><span>This app</span><code class="sm-app"></code><button class="np-btn" type="button" data-act="app">Copy</button></div>
|
|
||||||
${isUploadId(meta.id) ? '' : '<div class="sm-link"><span>YouTube</span><code class="sm-yt"></code><button class="np-btn" type="button" data-act="yt">Copy</button></div>'}
|
|
||||||
</div>
|
|
||||||
<div class="sm-gif">
|
|
||||||
<div class="sm-gif-controls">
|
|
||||||
<label>GIF length <select class="sm-len"><option value="2">2 s</option><option value="3" selected>3 s</option><option value="5">5 s</option></select></label>
|
|
||||||
<button class="np-btn" type="button" data-act="gif">🎞 Make GIF</button>
|
|
||||||
</div>
|
|
||||||
<div class="sm-gif-out"></div>
|
|
||||||
</div>
|
|
||||||
<div class="sm-gif sm-bite">
|
|
||||||
<div class="sm-gif-controls">
|
|
||||||
<label>🔔 Soundbite <input class="sm-b-start" inputmode="decimal" /></label>
|
|
||||||
<label>to <input class="sm-b-end" inputmode="decimal" /></label>
|
|
||||||
<select class="sm-b-fmt"><option value="mp3">MP3</option><option value="m4r">iPhone ringtone (.m4r)</option></select>
|
|
||||||
<button class="np-btn" type="button" data-act="bite">Make</button>
|
|
||||||
</div>
|
|
||||||
<div class="sm-bite-out"></div>
|
|
||||||
</div>`;
|
|
||||||
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 = '<div class="sm-busy">Making the GIF…</div>';
|
|
||||||
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 = `<img class="sm-preview" alt="GIF preview" src="${url}" />
|
|
||||||
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">⤓ Download</a>
|
|
||||||
${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/gif' })] }) ? '<button class="np-btn" type="button" data-act="sharegif">↗ Share GIF</button>' : ''}</div>`;
|
|
||||||
out.dataset.name = name;
|
|
||||||
} catch (err) {
|
|
||||||
out.innerHTML = `<div class="sm-err">⚠ ${escapeHtml(err.message)}</div>`;
|
|
||||||
} 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 = '<div class="sm-err">⚠ Times must look like 1:23, end after start</div>'; return; }
|
|
||||||
if (b - a > max) { out.innerHTML = `<div class="sm-err">⚠ Max ${max} s for ${fmt === 'm4r' ? 'an iPhone ringtone' : 'a soundbite'}</div>`; return; }
|
|
||||||
out.innerHTML = '<div class="sm-busy">Cutting the soundbite…</div>';
|
|
||||||
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 = `<audio controls src="${url}" class="sm-audio"></audio>
|
|
||||||
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">⤓ Download</a>
|
|
||||||
${navigator.canShare && navigator.canShare({ files: [new File([biteBlob], name, { type: out.dataset.type })] }) ? '<button class="np-btn" type="button" data-act="sharebite">↗ Share</button>' : ''}</div>
|
|
||||||
${fmt === 'm4r' ? '<p class="sp-note">iPhone: share it to GarageBand (or drag into Finder with the phone connected) to set it as a ringtone.</p>' : ''}`;
|
|
||||||
} catch (err) {
|
|
||||||
out.innerHTML = `<div class="sm-err">⚠ ${escapeHtml(err.message)}</div>`;
|
|
||||||
} 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=<id>&t=<sec> — 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
|
// Loudness levelling (EBU R128) — every track toward -14 LUFS
|
||||||
@@ -6087,78 +5943,8 @@ const Gestures = (() => {
|
|||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
|
||||||
const External = (() => {
|
const External = Lazy.proxy('feature:share-external', ['open', 'm3u', 'intent'], '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 = '<div class="sm-busy">Checking the server copy…</div>';
|
|
||||||
showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
|
|
||||||
const ok = await cached(meta.id);
|
|
||||||
if (!ok) {
|
|
||||||
body.innerHTML = `<p class="modal-text">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.</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = streamUrl(meta.id);
|
|
||||||
const title = meta.title || meta.id;
|
|
||||||
const inList = queue.length > 1 && queueIndex >= 0;
|
|
||||||
body.innerHTML = `
|
|
||||||
<div class="sm-link"><span>Stream</span><code>${escapeHtml(url)}</code><button class="np-btn" type="button" data-act="copy">Copy</button></div>
|
|
||||||
<div class="ext-buttons">
|
|
||||||
${isAndroid ? `
|
|
||||||
<a class="np-btn" href="${escapeHtml(intent(url, title, 'org.videolan.vlc'))}">▶ VLC</a>
|
|
||||||
<a class="np-btn" href="${escapeHtml(intent(url, title, 'com.mxtech.videoplayer.ad'))}">▶ MX Player</a>
|
|
||||||
<a class="np-btn" href="${escapeHtml(intent(url, title))}">▶ Open with…</a>`
|
|
||||||
: isIOS ? `
|
|
||||||
<a class="np-btn" href="vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(url)}">▶ VLC</a>
|
|
||||||
<a class="np-btn" href="infuse://x-callback-url/play?url=${encodeURIComponent(url)}">▶ Infuse</a>`
|
|
||||||
: `<a class="np-btn" href="vlc://${escapeHtml(url)}">▶ VLC</a>`}
|
|
||||||
<button class="np-btn" type="button" data-act="m3u">⤓ .m3u (this video)</button>
|
|
||||||
${inList ? '<button class="np-btn" type="button" data-act="m3u-list">⤓ .m3u (whole list)</button>' : ''}
|
|
||||||
</div>
|
|
||||||
<p class="sp-note">${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.'}</p>`;
|
|
||||||
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)
|
// 🎉 Watch party — synced playback + chat + voice (server/party.js relays)
|
||||||
@@ -9341,7 +9127,7 @@ async function boot() {
|
|||||||
await Presenter.boot();
|
await Presenter.boot();
|
||||||
if (LowerThird.active()) return;
|
if (LowerThird.active()) return;
|
||||||
SectionRail.boot();
|
SectionRail.boot();
|
||||||
Share.bootFromUrl();
|
if (isMediaId(new URLSearchParams(location.search).get('v'))) Share.bootFromUrl().catch(error => toast(error.message));
|
||||||
bootViewFromUrl();
|
bootViewFromUrl();
|
||||||
bootSearchFromUrl();
|
bootSearchFromUrl();
|
||||||
if (WEB) bootShareFromUrl();
|
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(); });
|
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($('navPartyBtn'),'feature:party-dj'); Lazy.intent($('navRemoteBtn'),'feature:direct');
|
||||||
Lazy.intent($('remoteChip'),'feature:direct'); Lazy.intent($('eqBtn'),'feature:eq-core');
|
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?.();
|
Lazy.reportLayout?.(); Lazy.warm?.();
|
||||||
if (WEB) {
|
if (WEB) {
|
||||||
fetch('/api/user/sync', {
|
fetch('/api/user/sync', {
|
||||||
|
|||||||
@@ -340,6 +340,14 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"/watch-party-client.js"
|
"/watch-party-client.js"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"feature:share-external": {
|
||||||
|
"contract": 1,
|
||||||
|
"eager": false,
|
||||||
|
"background": true,
|
||||||
|
"files": [
|
||||||
|
"/share-external.js"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
221
frontend/share-external.js
Normal file
221
frontend/share-external.js
Normal file
@@ -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 = `
|
||||||
|
<label class="sm-row">Moment <input class="sm-time" inputmode="decimal" value="${LyricsCore.fmtClock(t0)}" /></label>
|
||||||
|
<div class="sm-links">
|
||||||
|
<div class="sm-link"><span>This app</span><code class="sm-app"></code><button class="np-btn" type="button" data-act="app">Copy</button></div>
|
||||||
|
${isUploadId(meta.id) ? '' : '<div class="sm-link"><span>YouTube</span><code class="sm-yt"></code><button class="np-btn" type="button" data-act="yt">Copy</button></div>'}
|
||||||
|
</div>
|
||||||
|
<div class="sm-gif">
|
||||||
|
<div class="sm-gif-controls">
|
||||||
|
<label>GIF length <select class="sm-len"><option value="2">2 s</option><option value="3" selected>3 s</option><option value="5">5 s</option></select></label>
|
||||||
|
<button class="np-btn" type="button" data-act="gif">🎞 Make GIF</button>
|
||||||
|
</div>
|
||||||
|
<div class="sm-gif-out"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sm-gif sm-bite">
|
||||||
|
<div class="sm-gif-controls">
|
||||||
|
<label>🔔 Soundbite <input class="sm-b-start" inputmode="decimal" /></label>
|
||||||
|
<label>to <input class="sm-b-end" inputmode="decimal" /></label>
|
||||||
|
<select class="sm-b-fmt"><option value="mp3">MP3</option><option value="m4r">iPhone ringtone (.m4r)</option></select>
|
||||||
|
<button class="np-btn" type="button" data-act="bite">Make</button>
|
||||||
|
</div>
|
||||||
|
<div class="sm-bite-out"></div>
|
||||||
|
</div>`;
|
||||||
|
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 = '<div class="sm-busy">Making the GIF…</div>';
|
||||||
|
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 = `<img class="sm-preview" alt="GIF preview" src="${url}" />
|
||||||
|
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">⤓ Download</a>
|
||||||
|
${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/gif' })] }) ? '<button class="np-btn" type="button" data-act="sharegif">↗ Share GIF</button>' : ''}</div>`;
|
||||||
|
out.dataset.name = name;
|
||||||
|
} catch (err) {
|
||||||
|
out.innerHTML = `<div class="sm-err">⚠ ${escapeHtml(err.message)}</div>`;
|
||||||
|
} 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 = '<div class="sm-err">⚠ Times must look like 1:23, end after start</div>'; return; }
|
||||||
|
if (b - a > max) { out.innerHTML = `<div class="sm-err">⚠ Max ${max} s for ${fmt === 'm4r' ? 'an iPhone ringtone' : 'a soundbite'}</div>`; return; }
|
||||||
|
out.innerHTML = '<div class="sm-busy">Cutting the soundbite…</div>';
|
||||||
|
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 = `<audio controls src="${url}" class="sm-audio"></audio>
|
||||||
|
<div class="sm-gif-actions"><a class="np-btn" href="${url}" download="${escapeHtml(name)}">⤓ Download</a>
|
||||||
|
${navigator.canShare && navigator.canShare({ files: [new File([biteBlob], name, { type: out.dataset.type })] }) ? '<button class="np-btn" type="button" data-act="sharebite">↗ Share</button>' : ''}</div>
|
||||||
|
${fmt === 'm4r' ? '<p class="sp-note">iPhone: share it to GarageBand (or drag into Finder with the phone connected) to set it as a ringtone.</p>' : ''}`;
|
||||||
|
} catch (err) {
|
||||||
|
out.innerHTML = `<div class="sm-err">⚠ ${escapeHtml(err.message)}</div>`;
|
||||||
|
} 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=<id>&t=<sec> — 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 = '<div class="sm-busy">Checking the server copy…</div>';
|
||||||
|
showModal('📺 Open in another player', body, [{ label: 'Close', primary: true, onClick: closeModal }]);
|
||||||
|
const ok = await cached(meta.id);
|
||||||
|
if (!ok) {
|
||||||
|
body.innerHTML = `<p class="modal-text">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.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = streamUrl(meta.id);
|
||||||
|
const title = meta.title || meta.id;
|
||||||
|
const inList = queue.length > 1 && queueIndex >= 0;
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="sm-link"><span>Stream</span><code>${escapeHtml(url)}</code><button class="np-btn" type="button" data-act="copy">Copy</button></div>
|
||||||
|
<div class="ext-buttons">
|
||||||
|
${isAndroid ? `
|
||||||
|
<a class="np-btn" href="${escapeHtml(intent(url, title, 'org.videolan.vlc'))}">▶ VLC</a>
|
||||||
|
<a class="np-btn" href="${escapeHtml(intent(url, title, 'com.mxtech.videoplayer.ad'))}">▶ MX Player</a>
|
||||||
|
<a class="np-btn" href="${escapeHtml(intent(url, title))}">▶ Open with…</a>`
|
||||||
|
: isIOS ? `
|
||||||
|
<a class="np-btn" href="vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(url)}">▶ VLC</a>
|
||||||
|
<a class="np-btn" href="infuse://x-callback-url/play?url=${encodeURIComponent(url)}">▶ Infuse</a>`
|
||||||
|
: `<a class="np-btn" href="vlc://${escapeHtml(url)}">▶ VLC</a>`}
|
||||||
|
<button class="np-btn" type="button" data-act="m3u">⤓ .m3u (this video)</button>
|
||||||
|
${inList ? '<button class="np-btn" type="button" data-act="m3u-list">⤓ .m3u (whole list)</button>' : ''}
|
||||||
|
</div>
|
||||||
|
<p class="sp-note">${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.'}</p>`;
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
@@ -143,6 +143,7 @@ const SHELL = [
|
|||||||
'/presenter.js',
|
'/presenter.js',
|
||||||
'/remote-client.js',
|
'/remote-client.js',
|
||||||
'/watch-party-client.js',
|
'/watch-party-client.js',
|
||||||
|
'/share-external.js',
|
||||||
'/app.js',
|
'/app.js',
|
||||||
'/manifest.webmanifest',
|
'/manifest.webmanifest',
|
||||||
'/icons/icon-192.png',
|
'/icons/icon-192.png',
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ async function run(name){
|
|||||||
}
|
}
|
||||||
if(groups.includes('feature:share-external')) {
|
if(groups.includes('feature:share-external')) {
|
||||||
await page.evaluate(async()=>{current={meta:{id:'benchmedia01',title:'Cached smoke',channel:'Fixture'}};await Share.open();});
|
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/);
|
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');
|
await page.evaluate(()=>closeModal());checked.push('share');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user