Watch party reader view: presented websites become clean text with per-device font size, landscape/portrait and an Original switch; SSRF-guarded server fetch
This commit is contained in:
@@ -4088,6 +4088,30 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
|
||||
.pp-bar .np-btn { min-height: 40px; text-decoration: none; display: inline-flex; align-items: center; }
|
||||
.pp-frame { flex: 1; width: 100%; border: 0; background: #fff; }
|
||||
.pp-hint { margin: 0; padding: 6px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-2); }
|
||||
.pp-hint[hidden], .pp-frame[hidden], .pp-rtools[hidden] { display: none; }
|
||||
.pp-rtools { display: inline-flex; gap: 6px; }
|
||||
.pp-reader { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; background: var(--bg); }
|
||||
.pp-reader[hidden] { display: none; }
|
||||
.pp-article { max-width: 68ch; margin: 0 auto; padding: 24px 20px 64px; font: 400 var(--pp-size, 20px)/1.65 var(--ui, system-ui), sans-serif; color: var(--text); overflow-wrap: anywhere; }
|
||||
.pp-article h1 { font-family: var(--display, inherit); font-size: 1.6em; line-height: 1.15; margin: 0 0 .3em; text-wrap: balance; }
|
||||
.pp-article h2, .pp-article h3 { line-height: 1.25; margin: 1.4em 0 .4em; }
|
||||
.pp-article .pp-by { margin: 0 0 1.4em; color: var(--text-2); font-size: .8em; }
|
||||
.pp-article p, .pp-article li { margin: 0 0 .9em; }
|
||||
.pp-article img { max-width: 100%; height: auto; border-radius: 6px; }
|
||||
.pp-article a { color: var(--accent, #f06b53); text-underline-offset: 3px; }
|
||||
.pp-article blockquote { margin: 1em 0; padding-left: 1em; border-left: 2px solid var(--line); color: var(--text-2); }
|
||||
.pp-article pre { overflow-x: auto; font-size: .8em; }
|
||||
.pp-article table { display: block; overflow-x: auto; font-size: .85em; border-collapse: collapse; }
|
||||
.pp-article td, .pp-article th { padding: 4px 8px; border: 1px solid var(--line); }
|
||||
.pp-msg { padding: 32px 20px 0; text-align: center; color: var(--text-2); }
|
||||
.party-present.landscape { inset: 0; border-radius: 0; z-index: 900; }
|
||||
@media (orientation: portrait) {
|
||||
.party-present.landscape { inset: auto; top: 0; left: 100vw; width: 100vh; width: 100dvh; height: 100vw; transform-origin: top left; transform: rotate(90deg); }
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.pp-bar { flex-wrap: wrap; }
|
||||
.pp-what { flex-basis: 100%; }
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.party-present { inset: calc(8px + env(safe-area-inset-top, 0px)) 8px calc(8px + env(safe-area-inset-bottom, 0px)) 8px; z-index: 165; }
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
|
||||
dj: { enabled: false, entries: [] }, djPending: false,
|
||||
allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
|
||||
loadingId: '', restoring: false, inviteCode: '', hiddenAt: 0, present: null, presentHidden: false, invites: [], inviteError: '',
|
||||
loadingId: '', restoring: false, inviteCode: '', hiddenAt: 0, present: null, presentHidden: false, invites: [], inviteError: '', viewMode: {}, reader: { url: '', html: '', error: '', loading: false },
|
||||
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
|
||||
minimized: false,
|
||||
};
|
||||
@@ -548,18 +548,24 @@
|
||||
el.innerHTML = `<header class="pp-bar">
|
||||
<span class="pp-what"></span>
|
||||
<span class="pp-pages"><button type="button" class="np-btn" data-pp="prev" aria-label="Previous page">‹</button><span class="pp-page"></span><button type="button" class="np-btn" data-pp="next" aria-label="Next page">›</button></span>
|
||||
<span class="pp-rtools"><button type="button" class="np-btn" data-pp="smaller" aria-label="Smaller text">A−</button><button type="button" class="np-btn" data-pp="bigger" aria-label="Larger text">A+</button><button type="button" class="np-btn" data-pp="rotate" aria-pressed="false">⟳ Landscape</button></span>
|
||||
<button type="button" class="np-btn" data-pp="mode"></button>
|
||||
<a class="np-btn pp-open" target="_blank" rel="noopener noreferrer">Open</a>
|
||||
<button type="button" class="np-btn" data-pp="hide">Hide</button>
|
||||
</header>
|
||||
<iframe class="pp-frame" title="Shared presentation" referrerpolicy="no-referrer" allow="fullscreen"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation"></iframe>
|
||||
<div class="pp-reader" hidden></div>
|
||||
<p class="pp-hint">Blank? Some sites don't allow embedding — tap Open to view it in a new tab.</p>`;
|
||||
el.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('[data-pp]'); if (!b || !st.present) return;
|
||||
if (b.dataset.pp === 'hide') { st.presentHidden = true; renderPresent(); render(); return; }
|
||||
if (b.dataset.pp === 'smaller' || b.dataset.pp === 'bigger') { readerSize(readerSize() + (b.dataset.pp === 'bigger' ? 2 : -2)); renderPresent(); return; }
|
||||
if (b.dataset.pp === 'rotate') { setPresentLandscape(!el.classList.contains('landscape')); return; }
|
||||
if (b.dataset.pp === 'mode') { st.viewMode[st.present.url] = viewOf(st.present) === 'reader' ? 'embed' : 'reader'; renderPresent(); return; }
|
||||
if (!canPresent()) return;
|
||||
const page = Math.max(1, (st.present.page || 1) + (b.dataset.pp === 'next' ? 1 : -1));
|
||||
send({ type: 'present', present: { url: st.present.url, page } });
|
||||
send({ type: 'present', present: { url: st.present.url, page, mode: st.present.mode } });
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!st.present || st.presentHidden || !canPresent() || e.target.closest?.('input, textarea')) return;
|
||||
@@ -569,10 +575,55 @@
|
||||
document.body.append(el);
|
||||
return el;
|
||||
}
|
||||
// Reader view: the server turns the page into clean text; each device picks
|
||||
// its own size and orientation, and can switch to the original page.
|
||||
const viewOf = (p) => st.viewMode[p.url] || p.mode || 'embed';
|
||||
const readerSize = (v) => {
|
||||
if (v !== undefined) { ls.set('ytpReaderSize', String(Math.max(14, Math.min(44, v)))); }
|
||||
return Number(ls.get('ytpReaderSize')) || 20;
|
||||
};
|
||||
async function setPresentLandscape(on) {
|
||||
const el = $('partyPresent'); if (!el) return;
|
||||
el.classList.toggle('landscape', !!on);
|
||||
const b = el.querySelector('[data-pp="rotate"]'); b.setAttribute('aria-pressed', String(!!on)); b.textContent = on ? '⟲ Portrait' : '⟳ Landscape';
|
||||
try {
|
||||
if (on && document.documentElement.requestFullscreen && screen.orientation?.lock) { if (!document.fullscreenElement) await document.documentElement.requestFullscreen(); await screen.orientation.lock('landscape'); }
|
||||
else if (!on) { try { screen.orientation?.unlock?.(); } catch { /* not locked */ } if (document.fullscreenElement) await document.exitFullscreen(); }
|
||||
} catch { /* iOS: the CSS rotation covers it */ }
|
||||
}
|
||||
async function loadReader(url) {
|
||||
if (st.reader.url === url && (st.reader.html || st.reader.loading)) return;
|
||||
st.reader = { url, html: '', error: '', loading: true }; renderPresent();
|
||||
try {
|
||||
const j = await (await fetch(`/api/party/${encodeURIComponent(st.code)}/reader`)).json();
|
||||
if (st.reader.url !== url) return;
|
||||
if (!j.ok) throw new Error(j.error || 'Could not read that page');
|
||||
st.reader = { url, loading: false, error: '', html: `<article class="pp-article"><h1>${escapeHtml(j.title || '')}</h1>
|
||||
<p class="pp-by">${escapeHtml([j.byline, j.site].filter(Boolean).join(' · '))}</p>${j.content}</article>` };
|
||||
} catch (err) { if (st.reader.url === url) st.reader = { url, loading: false, html: '', error: err.message }; }
|
||||
renderPresent();
|
||||
}
|
||||
function renderPresent() {
|
||||
const p = st.present;
|
||||
if (!p) { const el = $('partyPresent'); if (el) { el.classList.add('hidden'); el.querySelector('.pp-frame').removeAttribute('src'); } return; }
|
||||
if (!p) { const el = $('partyPresent'); if (el) { el.classList.add('hidden'); el.classList.remove('landscape'); el.querySelector('.pp-frame').removeAttribute('src'); } return; }
|
||||
const el = presentEl(), e = embedOf(p.url, p.page || 1);
|
||||
const reading = viewOf(p) === 'reader';
|
||||
el.classList.toggle('reading', reading);
|
||||
el.querySelector('.pp-rtools').hidden = !reading;
|
||||
el.querySelector('[data-pp="mode"]').textContent = reading ? 'Original' : 'Reader';
|
||||
const box = el.querySelector('.pp-reader');
|
||||
box.hidden = !reading;
|
||||
el.querySelector('.pp-frame').hidden = reading;
|
||||
el.querySelector('.pp-hint').hidden = reading;
|
||||
if (reading) {
|
||||
el.style.setProperty('--pp-size', readerSize() + 'px');
|
||||
if (st.reader.url !== p.url) loadReader(p.url);
|
||||
const r = st.reader;
|
||||
const html = r.loading ? '<p class="pp-msg">Making the page easy to read…</p>'
|
||||
: r.error ? `<p class="pp-msg">${escapeHtml(r.error)}</p><p class="pp-msg"><button type="button" class="np-btn" data-pp="mode">Show the original page</button></p>` : r.html;
|
||||
if (box.dataset.for !== p.url + '|' + html.length) { box.innerHTML = html; box.dataset.for = p.url + '|' + html.length; }
|
||||
el.querySelector('.pp-frame').removeAttribute('src');
|
||||
}
|
||||
el.classList.toggle('hidden', st.presentHidden);
|
||||
el.querySelector('.pp-what').textContent = `${p.by || 'Host'} · ${e.label}`;
|
||||
el.querySelector('.pp-pages').hidden = !e.paged;
|
||||
@@ -580,18 +631,21 @@
|
||||
for (const b of el.querySelectorAll('.pp-pages button')) b.hidden = !canPresent();
|
||||
el.querySelector('.pp-open').href = p.url;
|
||||
const frame = el.querySelector('.pp-frame');
|
||||
if (frame.getAttribute('src') !== e.src) frame.setAttribute('src', e.src);
|
||||
if (!reading && frame.getAttribute('src') !== e.src) frame.setAttribute('src', e.src);
|
||||
}
|
||||
function presentPrompt() {
|
||||
const body = document.createElement('div');
|
||||
body.innerHTML = `<p class="modal-text">Everyone in the party sees it full screen. Google Slides and PDFs follow your page — use ‹ › or the arrow keys.</p>
|
||||
<input id="ppUrl" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://docs.google.com/presentation/d/…" value="${escapeHtml(st.present?.url || '')}" />
|
||||
<input id="ppUrl" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://… a website, Google Slides/Docs or a PDF" value="${escapeHtml(st.present?.url || '')}" />
|
||||
<label class="pt-toggle" style="margin-top:8px"><input type="checkbox" id="ppReader" ${st.present && st.present.mode === 'embed' ? '' : 'checked'} /> Reader view for websites — clean text everyone can enlarge</label>
|
||||
<p class="modal-text" style="font-size:12px">Google files must be shared “Anyone with the link”. Sites like Facebook or banks refuse embedding; guests can still tap Open.</p>`;
|
||||
const go = () => {
|
||||
let url = ($('ppUrl').value || '').trim();
|
||||
if (url && !/^https?:\/\//i.test(url)) url = 'https://' + url;
|
||||
try { new URL(url); } catch { toast('Paste a full link, e.g. https://…'); return; }
|
||||
closeModal(); send({ type: 'present', present: { url, page: 1 } });
|
||||
const special = embedOf(url, 1); // Google files and PDFs always embed
|
||||
const mode = $('ppReader').checked && !special.paged && !/Google/.test(special.label || '') ? 'reader' : 'embed';
|
||||
closeModal(); send({ type: 'present', present: { url, page: 1, mode } });
|
||||
};
|
||||
showModal('🖥 Present to the party', body, [{ label: 'Cancel', onClick: closeModal }, { label: 'Present', primary: true, onClick: go }]);
|
||||
setTimeout(() => { const i = $('ppUrl'); if (i) { i.focus(); i.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } }); } }, 50);
|
||||
|
||||
Reference in New Issue
Block a user