From 0475a1826c0ab7d0d5584237cbd4d7422e136144 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Thu, 8 Oct 2026 04:44:35 +0800 Subject: [PATCH] Load sharing and external-player tools on demand --- frontend/app-seams.test.js | 11 ++ frontend/app.js | 221 +------------------------------------ frontend/assets.json | 8 ++ frontend/index.html | 2 +- frontend/share-external.js | 221 +++++++++++++++++++++++++++++++++++++ frontend/sw.js | 1 + perf/seams.mjs | 4 +- 7 files changed, 249 insertions(+), 219 deletions(-) create mode 100644 frontend/share-external.js 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 = ` - - -
-
- - -
-
-
-
-
- - - - -
-
-
`; - 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 = `GIF preview -
⤓ Download - ${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/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 })] }) ? '' : ''}
- ${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 = ``; - return; - } - const url = streamUrl(meta.id); - const title = meta.title || meta.id; - const inList = queue.length > 1 && queueIndex >= 0; - body.innerHTML = ` - -
- ${isAndroid ? ` - ▶ VLC - ▶ MX Player - ▶ Open with…` - : isIOS ? ` - ▶ VLC - ▶ Infuse` - : `▶ VLC`} - - ${inList ? '' : ''} -
-

${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 = ` + + +
+
+ + +
+
+
+
+
+ + + + +
+
+
`; + 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 = `GIF preview +
⤓ Download + ${navigator.canShare && navigator.canShare({ files: [new File([gifBlob], name, { type: 'image/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 })] }) ? '' : ''}
+ ${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 = ``; + return; + } + const url = streamUrl(meta.id); + const title = meta.title || meta.id; + const inList = queue.length > 1 && queueIndex >= 0; + body.innerHTML = ` + +
+ ${isAndroid ? ` + ▶ VLC + ▶ MX Player + ▶ Open with…` + : isIOS ? ` + ▶ VLC + ▶ Infuse` + : `▶ VLC`} + + ${inList ? '' : ''} +
+

${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'); }