aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js101
1 files changed, 98 insertions, 3 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 094de0d..dadd084 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -8911,8 +8911,57 @@ export function messagesFeature(ctx) {
w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add));
w.stops.push(subscribeOn(relays, { kinds: [7, 5], '#a': [s.a], limit: 1000 }, e => takeLiveAction(w, e)));
w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); }));
+ // who's here: NIP-53 presence (kind 10312, the stream in its a tag) —
+ // Shosho, zap.stream and others say it, and so do we while it's open
+ w.here = new Map(); // pubkey -> when they last said so
+ w.stops.push(subscribeOn(relays, { kinds: [PRESENCE_KIND], '#a': [s.a], since: Math.floor(Date.now() / 1000) - PRESENCE_FRESH }, (e) => {
+ if (liveWatch !== w || (w.here.get(e.pubkey) || 0) >= e.created_at) return;
+ w.here.set(e.pubkey, e.created_at);
+ scheduleRepaint();
+ }));
+ sayHere(w);
return w;
}
+ // Presence counts for half an hour (some clients say it once, on joining);
+ // ours is said again every five minutes while the stream is open, and
+ // taken back (an empty one) on leaving. Only with a key held here: a
+ // signer app would be asked to sign every five minutes.
+ const PRESENCE_KIND = 10312, PRESENCE_FRESH = 1800;
+ async function presence(w, here) {
+ const id = await identity().catch(() => null);
+ if (!id || !(id.signer instanceof Uint8Array) || isVisitor()) return;
+ const ev = liveSeen.get(w.a);
+ if (here && (!ev || liveHost(ev) === id.pubkey || !isLiveNow(ev))) return; // not for your own, nor one that's over
+ const evt = finalizeEvent({ kind: PRESENCE_KIND, created_at: Math.floor(Date.now() / 1000), content: '',
+ tags: here ? [['a', w.a, w.relays[0] || '', 'root'], CLIENT_TAG] : [CLIENT_TAG] }, id.signer);
+ if (here) { w.here.set(evt.pubkey, evt.created_at); scheduleRepaint(); w.said = true; }
+ publishOn(w.relays, evt).catch(() => {});
+ }
+ function sayHere(w) {
+ presence(w, true);
+ w.hereTimer = setInterval(() => { if (liveWatch === w) presence(w, true); }, 5 * 60_000);
+ }
+ // the people known to be watching (fresh presence), newest first
+ const watchersOf = (w) => {
+ const since = Date.now() / 1000 - PRESENCE_FRESH;
+ return w && w.here ? [...w.here].filter(([, at]) => at >= since).sort((a, b) => b[1] - a[1]).map(([pk]) => pk) : [];
+ };
+ // "4 watching": hover names who (as many as are known), a tap shows their faces
+ function watchingLine(w, n, cls = 'small muted') {
+ const who = watchersOf(w);
+ const names = who.slice(0, 30).map((pk) => displayName(pk)).join(', ') + (who.length > 30 ? '…' : '');
+ return h('span', { class: cls + (who.length ? ' live-watching has-who' : ' live-watching'), title: who.length ? names : undefined,
+ role: who.length ? 'button' : undefined,
+ onClick: who.length ? (e) => { e.stopPropagation(); w.showWho = !w.showWho; render(); } : undefined },
+ t('liveWatching', { n: Math.max(n || 0, who.length) }));
+ }
+ function watchersRow(w) {
+ const who = watchersOf(w);
+ if (!w || !w.showWho || !who.length) return null;
+ return h('div', { class: 'live-who', 'data-key': 'live-who' },
+ ...who.slice(0, 60).map((pk) => h('button', { type: 'button', class: 'live-who-p', 'data-key': 'who:' + pk, title: displayName(pk), onClick: () => openProfile(pk) },
+ avatar(pk, 'chat-avatar mini', false), h('span', { class: 'small' }, displayName(pk)))));
+ }
// Include reactions/receipts from clients that only tag the comment's e,
// plus deletions of known reactions. Batch changes into one subscription.
function watchLiveActions(w) {
@@ -9028,6 +9077,8 @@ export function messagesFeature(ctx) {
if (w.actionStop) w.actionStop();
if (w.sheet) ui.emojiPick = null;
for (const stop of w.stops) { try { stop(); } catch {} }
+ clearInterval(w.hereTimer);
+ if (w.said) presence(w, false); // gone
stopLivePlayer(w);
}
// What to play: the stream while it's on, its recording once it's over.
@@ -9222,7 +9273,7 @@ export function messagesFeature(ctx) {
liveBadge(ev)),
mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' },
h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)),
- h('span', { class: 'small muted' }, t('liveWatching', { n: bc.viewers || 0 })),
+ watchingLine(w, bc.viewers || 0),
bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting'))
: bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null,
// camera ⇄ screen, without stopping the stream
@@ -9244,7 +9295,7 @@ export function messagesFeature(ctx) {
h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' },
avatar(host, 'chat-avatar mini'),
h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)),
- n != null && status === 'live' ? h('span', { class: 'small muted' }, t('liveWatching', { n })) : null,
+ n != null && status === 'live' ? watchingLine(w, n) : null,
h('span', { style: 'margin-left:auto' }),
canZapPk(host) ? h('button', { class: 'btn-sm', onClick: (e) => { setZapAddr(ev.id, s.a); zapNote(host, ev, e.currentTarget.getBoundingClientRect()); } }, '⚡ ' + t('liveZap')) : null,
// ours to answer for: anyone signed in can report it, an admin can take it down
@@ -9260,6 +9311,7 @@ export function messagesFeature(ctx) {
const url = location.origin + '/' + packNaddr(s.a);
try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {}
} }, h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/></svg>' }))),
+ watchersRow(w),
liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null,
ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null),
h('div', { class: 'card col', style: 'gap:8px' },
@@ -14516,8 +14568,20 @@ export function messagesFeature(ctx) {
if (j === i) return;
const v = s.querySelector('video.mp-media');
if (eager && k >= 0) {
+ // the next clip: a player of its own, warm — once its opening is in
+ // the cache, given the file to read up to its first frame (preload
+ // 'metadata': Chrome reads ~2 s of it and stops) and paused there,
+ // so the swipe onto it is only play and sound (TikTok's trick). The
+ // others ahead get their opening only.
+ if (k === 0 && v.getAttribute('src')) return; // warm already
pagerRelease(s);
- headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5);
+ const head = headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5);
+ if (k === 0) head.then(() => {
+ if (!pager || !s.isConnected || pager.cur === s || v.getAttribute('src') || pagerAhead(pager.cur)[0] !== s) return;
+ v.preload = 'metadata';
+ v.muted = true;
+ pagerAttach(s);
+ });
return;
}
if (k === 0) {
@@ -14716,6 +14780,7 @@ export function messagesFeature(ctx) {
else v.addEventListener('loadedmetadata', () => { try { v.currentTime = at; } catch {} }, { once: true });
}
v.muted = !pagerLoud;
+ clockStart(slide, v);
v.play().catch((err) => {
// sound needs a tap the browser counted: play quietly, the button says so
if (err && err.name === 'NotAllowedError' && !v.muted) {
@@ -14725,6 +14790,35 @@ export function messagesFeature(ctx) {
}
});
}
+ // How long from landing on a clip to its picture moving, kept for the
+ // last hundred (coinosPagerStarts() in the console, and dlog), with what
+ // shape the player was in when the swipe landed (warm: it had its frame).
+ const pagerStarts = [];
+ function clockStart(slide, v) {
+ const t0 = slide._arriveAt;
+ slide._arriveAt = 0;
+ if (!t0) return;
+ const url = v.getAttribute('data-src') || '', warm = v.readyState >= 2;
+ const d = videoInfo.get(url) || {};
+ let host = ''; try { host = new URL(url).hostname; } catch {}
+ const done = () => {
+ const ms = Math.round(performance.now() - t0);
+ pagerStarts.push({ ms, warm, host, mb: d.size ? +(d.size / 1e6).toFixed(1) : null, faststart: d.faststart ?? null, src: (v.getAttribute('src') || '').startsWith(VMEDIA) ? 'copy' : 'original', url });
+ if (pagerStarts.length > 100) pagerStarts.shift();
+ dlog('pager start', ms + ' ms', warm ? 'warm' : 'cold', host);
+ };
+ const tick = () => {
+ if (!v.isConnected || performance.now() - t0 > 20000) return;
+ if (v.currentTime > 0 && !v.paused) { done(); return; }
+ if (v.requestVideoFrameCallback) v.requestVideoFrameCallback(tick); else requestAnimationFrame(tick);
+ };
+ tick();
+ }
+ if (typeof window !== 'undefined') window.coinosPagerStarts = () => {
+ const ms = pagerStarts.map((x) => x.ms).sort((a, b) => a - b);
+ const q = (f) => ms.length ? ms[Math.min(ms.length - 1, Math.floor(f * ms.length))] : null;
+ return { n: ms.length, median: q(0.5), p90: q(0.9), max: ms.at(-1) ?? null, warm: pagerStarts.filter((x) => x.warm).length, starts: pagerStarts.slice() };
+ };
function pagerSound(on) {
pagerLoud = on;
if (!pager) return;
@@ -15133,6 +15227,7 @@ export function messagesFeature(ctx) {
pager.cur = slide;
pager.anchor = slide;
pager.curAt = Date.now();
+ slide._arriveAt = performance.now();
pagerSharpen(slide);
pagerArrows(slide);
pagerPosition();