aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js130
-rw-r--r--src/i18n.js2
-rw-r--r--src/media-timeline.js38
-rw-r--r--src/style.css47
4 files changed, 145 insertions, 72 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 02e418d..7824a6f 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1,4 +1,4 @@
-import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline } from '../media-timeline.js';
+import { mediaMoment, momentTags, readMoment, mediaClock, mediaTimeline, markAt } from '../media-timeline.js';
import { mediaServers } from '../media-servers.js';
// Messages — Concord community chat (CORD-01..05) + NIP-17 private DMs.
//
@@ -4522,38 +4522,47 @@ export function messagesFeature(ctx) {
text: [9735, 9737].includes(ev.kind) ? zapText(ev) : ev.content });
if (items.size > 300) items.delete(items.keys().next().value);
if (timedActivity.size > 200) timedActivity.delete(timedActivity.keys().next().value);
- queueMicrotask(() => document.querySelectorAll('audio, video').forEach(v => { if (v._timeline) v.dispatchEvent(new Event('mediaactivity')); }));
+ queueMicrotask(() => document.querySelectorAll('audio, video').forEach(v => { if (v._momentUrl) v.dispatchEvent(new Event('mediaactivity')); }));
}
+ // a post's timed activity on one of its clips, in order, each with who and what
+ const momentsOn = (id, file) => [...(timedActivity.get(id)?.values() || [])]
+ .filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev))
+ .sort((a, b) => a.moment.seconds - b.moment.seconds)
+ .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(x.text || x.icon).slice(0, 240) }));
+ // how a moment shows on a bar: a reaction as its emoji (a :code: as its
+ // picture, from the reaction's own tag), a zap ⚡, a reply ↩
+ function momentGlyph(x) {
+ if (x.ev.kind !== 7) return x.icon;
+ const code = shortcodeOf(x.ev.content);
+ const tag = code && x.ev.tags.find(t => t[0] === 'emoji' && t[1] === code && t[2]);
+ return tag ? reactImg(code, tag[2]) : reactNode(x.ev.content === '+' || !x.ev.content ? '❤️' : x.ev.content);
+ }
+ // Where a post's clip is up to, if it has been played: a reply, reaction or
+ // zap made then is about that moment. (No buttons of their own for it: the
+ // usual ones carry the time.)
+ function momentOf(ev) {
+ if (!ev) return null;
+ const cur = pager && pager.cur;
+ const players = cur && cur._ev && cur._ev.id === ev.id
+ ? [cur.querySelector('.mp-media')]
+ : [...document.querySelectorAll('[data-zap-post="' + CSS.escape(ev.id) + '"] :is(video, audio)')];
+ for (const v of players) {
+ if (v && v._momentUrl && v.currentTime > 0 && Number.isFinite(v.duration)) return mediaMoment(v._momentUrl, v.currentTime);
+ }
+ return null;
+ }
+ const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, mediaMoment: m } : ev; };
function attachMediaTimeline(player, url, explicitEvent) {
if (player._timeline) return;
player._timeline = true;
+ player._momentUrl = url;
const event = () => {
const ev = explicitEvent || noteInHand(player.closest('[data-zap-post]')?.getAttribute('data-zap-post'));
return ev && (String(ev.content || '').includes(url) || ev.tags.some(tag => tag.includes('url ' + url) || tag[1] === url)) ? ev : null;
};
const box = player.parentElement;
box._skipMorph = true;
- box.append(mediaTimeline(player, url, { h, event, label: t,
- moments: (id, file) => [...(timedActivity.get(id)?.values() || [])]
- .filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev))
- .sort((a, b) => a.moment.seconds - b.moment.seconds)
- .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(x.text || x.icon).slice(0, 240) })),
- action: (kind, ev, moment, button) => {
- if (signinAsk()) return;
- if (kind === 'comment') {
- player.pause();
- if (pager) leavePager();
- replyToNote(ev, moment);
- } else if (kind === 'reaction') {
- ui.reactPick = { ...ev, mediaMoment: moment }; render();
- } else {
- if (wallet.watchOnly) { zapLockedAsk(); return; }
- if (pager && !(ctx.zapDefaultSat && ctx.zapDefaultSat())) leavePager();
- zapNote(ev.pubkey, ev, button.getBoundingClientRect(), moment);
- recheckZap(ev.id);
- }
- },
- }));
+ box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph }));
player.dispatchEvent(new Event('timeupdate'));
}
function noteEvent(ev) {
@@ -10615,6 +10624,9 @@ export function messagesFeature(ctx) {
// only way nostr has: a deletion request for the reaction we sent.
// Reply: open (or re-aim) the thread and put the cursor in the box.
function replyToNote(ev, moment = null) {
+ // the post opened from a clip as it played: a reply to it is about that moment
+ const offered = ui.noteThread && ui.noteThread.momentFor;
+ if (!moment && offered && offered.id === ev.id) moment = offered.moment;
if (ui.noteThread && ui.noteThread.rootId === rootIdOf(ev)) {
ui.noteThread.focusId = ev.id;
ui.noteThread.seed = ev;
@@ -11746,16 +11758,16 @@ export function messagesFeature(ctx) {
const zapFlying = !!zp && zp.flying;
// tap pays the default amount; hold opens the screen that sets it
const zapHold = canZap ? holdable((el) => openZapSettings(pk, ev.id, el.getBoundingClientRect()),
- (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect()); recheckZap(ev.id); }) : null;
+ (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect(), momentOf(ev)); recheckZap(ev.id); }) : null;
const zapMine = !!((z && z.mine) || optimistic);
const zapLabel = zapFlying ? t('zapSending') : zapSats ? t('zapTallyTitle', { n: zapSats.toLocaleString() }) : t('zapTitle');
return h('div', { class: 'row note-acts' },
- btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev); }),
+ btn(I_REPLY, t('msgReply'), replyCount(ev.id), false, () => { if (!signinAsk()) replyToNote(ev, momentOf(ev)); }),
btn(I_BOOST, t('postBoost'), boostN, iBoosted(ev.id), () => boostNote(ev).catch(() => {})),
btn(I_QUOTE, t('postQuote'), 0, false, () => { if (!signinAsk()) quoteNote(ev); }),
// tap to choose how you feel about it; tap again to take it back
btn(mineReact ? postReactNode(mineReact) : I_HEART(false), t('postLike'), likeN, !!mineReact,
- () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = ev; render(); } }),
+ () => { if (signinAsk()) return; if (mineReact) unreact(ev).catch(() => {}); else { ui.reactPick = withMoment(ev); render(); } }),
// always in the bar — the same row of buttons on every post, so nothing
// shifts when a wallet comes online; without one it is just the number
btn(I_ZAP, zapLabel, zapSats ? fmtSats(zapSats) : 0, zapMine,
@@ -12368,7 +12380,11 @@ export function messagesFeature(ctx) {
class: 'col thread-reply' + (joined ? ' joined' : ''),
style: 'gap:8px;margin:0 -8px;padding:' + (joined ? '2px' : '8px') + ' 8px 10px;background:var(--accent-soft,rgba(128,128,128,.08));border-radius:' + (joined ? '0 0 8px 8px' : '8px'),
},
- s.mediaMoment ? h('div', { class: 'small muted' }, t('mediaCommentAt') + ' ' + mediaClock(s.mediaMoment.seconds)) : null,
+ // the moment of the clip this reply is about, and a way to make it about the whole post
+ s.mediaMoment ? h('div', { class: 'row small muted', style: 'gap:6px;align-items:center' },
+ t('mediaCommentAt') + ' ' + mediaClock(s.mediaMoment.seconds),
+ h('button', { type: 'button', class: 'btn-ghost btn-sm', 'aria-label': t('mediaMomentDrop'), title: t('mediaMomentDrop'),
+ style: 'padding:0 6px;min-height:0', onClick: () => { s.mediaMoment = null; if (s.momentFor) s.momentFor = null; render(); } }, '×')) : null,
s.preview ? draftPreview(s.draft) : null,
mediaBar(),
// A textarea that grows with the reply, like the chat composer: Enter
@@ -14454,18 +14470,22 @@ export function messagesFeature(ctx) {
h('button', { class: 'mp-like', type: 'button', 'aria-label': t('postLike'), onClick: stop(() => {
if (signinAsk()) { leavePager(); render(); return; }
if (myReactOn(ev.id)) { unreact(ev).catch(() => {}).finally(() => pagerCounts(slide)); setTimeout(() => pagerCounts(slide), 50); return; }
- ui.reactPick = ev; render();
+ ui.reactPick = withMoment(ev); render();
}) }, h('span', { class: 'i', html: I_HEART(false) }), h('span', { class: 'n' })),
// the post itself, as the feed would open it: the whole text, the replies
h('button', { class: 'mp-post', type: 'button', 'aria-label': t('mediaOpenPost'), title: t('mediaOpenPost'), onClick: stop(() => {
+ // a reply written there is about where the clip was
+ const moment = momentOf(ev);
leavePager(); openNoteThread(ev);
+ if (moment && ui.noteThread) ui.noteThread.momentFor = { id: ev.id, moment };
}) }, h('span', { class: 'i', html: I_REPLY }), h('span', { class: 'n' })),
h('button', { class: 'mp-zap', type: 'button', 'aria-label': t('zapTitle'), onClick: stop((e) => {
if (signinAsk()) { leavePager(); render(); return; }
// the first zap ever asks for an amount on a page of its own
const at = e.currentTarget.getBoundingClientRect();
+ const moment = momentOf(ev);
if (!(ctx.zapDefaultSat && ctx.zapDefaultSat())) leavePager();
- zapNote(ev.pubkey, ev, at);
+ zapNote(ev.pubkey, ev, at, moment);
recheckZap(ev.id);
setTimeout(() => pagerCounts(slide), 50);
}) }, h('span', { class: 'i', html: I_ZAP }), h('span', { class: 'n' })))));
@@ -14477,8 +14497,8 @@ export function messagesFeature(ctx) {
guardPicture(media, url);
media.muted = !pagerLoud;
media.addEventListener('click', () => {}); // a tap is the pager's (overlay), not the player's
- slide.append(seekBar(media));
- media.addEventListener('loadedmetadata', () => attachMediaTimeline(media, url, ev));
+ media._momentUrl = url;
+ slide.append(seekBar(media, () => momentsOn(ev.id, url)));
}
return slide;
}
@@ -14493,14 +14513,48 @@ export function messagesFeature(ctx) {
clearTimeout(bar._hide);
bar._hide = setTimeout(() => { if (bar._drag) revealSeek(bar); else bar.classList.remove('reveal'); }, 3500);
}
- function seekBar(v) {
+ // What was said, reacted or zapped at a moment of the clip sits over the
+ // bar at that moment (a tap goes there and says who and what), and floats
+ // up off it as the clip plays past.
+ function seekBar(v, moments = () => []) {
const fill = h('div', { class: 'mp-seek-fill' });
const time = h('span', { class: 'mp-seek-time' });
- const bar = h('div', { class: 'mp-seek' }, h('div', { class: 'mp-seek-track' }, fill), time);
+ const marks = h('div', { class: 'mp-marks' });
+ const bar = h('div', { class: 'mp-seek' }, marks, h('div', { class: 'mp-seek-track' }, fill), time);
+ let items = [], sig = '', checked = 0, last = 0;
+ const say = (x) => {
+ time.textContent = clock(x.moment.seconds) + ' ' + x.text;
+ bar.classList.add('say');
+ clearTimeout(bar._say);
+ bar._say = setTimeout(() => bar.classList.remove('say'), 3000);
+ revealSeek(bar);
+ };
+ const paintMarks = () => {
+ checked = performance.now();
+ items = moments();
+ const next = items.map((x) => x.id).join(',') + '@' + v.duration;
+ if (next === sig) return;
+ sig = next;
+ marks.replaceChildren(...items.map((x) => h('button', {
+ type: 'button', class: 'mp-mark', title: clock(x.moment.seconds) + ' ' + x.text,
+ style: 'left:' + markAt(x.moment.seconds, v.duration) + '%',
+ onPointerdown: (e) => e.stopPropagation(),
+ onClick: (e) => { e.stopPropagation(); try { v.currentTime = x.moment.seconds; } catch {} say(x); },
+ }, momentGlyph(x))));
+ };
+ v.addEventListener('mediaactivity', () => { checked = 0; });
+ // a moment the clip just played through floats up from its place
+ const pop = (x) => {
+ const slide = bar.closest('.mp-slide');
+ if (!slide || !pager || pager.cur !== slide) return;
+ const el = h('div', { class: 'mp-pop', style: 'left:calc(' + bar.offsetLeft + 'px + ' + (markAt(x.moment.seconds, v.duration) / 100) + ' * ' + bar.offsetWidth + 'px)' }, momentGlyph(x));
+ slide.append(el);
+ setTimeout(() => el.remove(), 1700);
+ };
const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / (r.width || 1))); };
const show = (f) => {
fill.style.width = (f * 100).toFixed(2) + '%';
- time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration);
+ if (!bar.classList.contains('say')) time.textContent = clock(f * v.duration) + ' / ' + clock(v.duration); // (unless it's saying a moment)
};
const to = (f) => { try { v.currentTime = f * v.duration; } catch {} };
bar.addEventListener('pointerdown', (e) => {
@@ -14509,6 +14563,7 @@ export function messagesFeature(ctx) {
try { bar.setPointerCapture(e.pointerId); } catch {}
bar._drag = { playing: !v.paused, id: e.pointerId };
bar.classList.add('drag');
+ bar.classList.remove('say');
v.pause();
const f = frac(e); show(f); to(f);
});
@@ -14534,7 +14589,12 @@ export function messagesFeature(ctx) {
if (bar._drag) return;
const ok = v.duration > 0 && isFinite(v.duration);
bar.classList.toggle('ready', ok);
- if (ok) show(v.currentTime / v.duration);
+ if (!ok) return;
+ show(v.currentTime / v.duration);
+ if (performance.now() - checked > 1000) paintMarks();
+ const now = v.currentTime;
+ if (!v.paused && now > last && now - last < 1) for (const x of items) if (x.moment.seconds > last && x.moment.seconds <= now) pop(x);
+ last = now;
};
return bar;
}
diff --git a/src/i18n.js b/src/i18n.js
index d780d00..a07cba4 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -36,7 +36,7 @@ export const isRTL = (l) => RTL.has(l || lang);
const STR = {
en: {
mediaSeek: 'Seek',
- mediaAtTime: 'at this time',
+ mediaMomentDrop: 'About the whole post, not this moment',
mediaCommentAt: 'Comment at',
arkHistoryIncomplete: 'History incomplete',
arkDetailsNotSynced: 'Payment details not synced',
diff --git a/src/media-timeline.js b/src/media-timeline.js
index bc9f4c7..8b6045e 100644
--- a/src/media-timeline.js
+++ b/src/media-timeline.js
@@ -76,28 +76,26 @@ export function loadWaveform(url) {
return job;
}
+// Where a moment sits along a bar: a percentage of the clip, or null.
+export function markAt(seconds, duration) {
+ return Number.isFinite(duration) && duration > 0 ? Math.min(100, Math.max(0, seconds / duration * 100)) : null;
+}
+
// The live player owns this subtree, so morphing cannot replace its handlers
-// with closures pointing at a detached player.
-export function mediaTimeline(player, url, { h, event, moments, action, label }) {
+// with closures pointing at a detached player. What was said, reacted or
+// zapped at a moment sits on the bar at that moment; replies, reactions and
+// zaps made while a clip plays carry its time on their own (see momentOf).
+export function mediaTimeline(player, url, { h, event, moments, glyph, label }) {
const audio = player.tagName === 'AUDIO';
const box = h('div', { class: 'media-timeline', onClick: e => e.stopPropagation(), onPointerdown: e => e.stopPropagation() });
box._skipMorph = true;
const bars = h('div', { class: 'media-wave', 'aria-hidden': 'true' });
const range = h('input', { type: 'range', min: 0, max: 1000, value: 0, step: 1, 'aria-label': label('mediaSeek'), disabled: true });
- const seek = h('div', { class: 'media-seek' }, bars, range);
- const time = h('span', { class: 'small muted' }, '0:00');
const marks = h('div', { class: 'media-marks' });
+ const seek = h('div', { class: 'media-seek' }, bars, range, marks);
+ const time = h('span', { class: 'small muted media-time' }, '0:00');
const detail = h('div', { class: 'small media-moment-detail', 'aria-live': 'polite' });
- const actions = h('div', { class: 'row media-moment-actions' }, time);
- for (const [kind, icon] of [['comment', '↩'], ['reaction', '♡'], ['zap', '⚡']]) {
- actions.append(h('button', { type: 'button', class: 'btn-sm', 'aria-label': label(kind === 'comment' ? 'msgReply' : kind === 'reaction' ? 'postLike' : 'zapTitle') + ' ' + label('mediaAtTime'), onClick: e => {
- e.stopPropagation();
- const ev = event();
- const m = mediaMoment(url, player.currentTime);
- if (ev && m) action(kind, ev, m, e.currentTarget);
- } }, icon + ' ' + label('mediaAtTime')));
- }
- box.append(seek, actions, marks, detail);
+ box.append(seek, time, detail);
let waveStarted = false, signature = '';
const draw = values => {
if (!values) return;
@@ -112,29 +110,27 @@ export function mediaTimeline(player, url, { h, event, moments, action, label })
const update = () => {
const ev = event();
const finite = Number.isFinite(player.duration) && player.duration > 0;
- actions.hidden = !ev || !finite;
range.disabled = !finite;
range.value = finite ? player.currentTime / player.duration * 1000 : 0;
range.setAttribute('aria-valuetext', mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0));
seek.style.setProperty('--played', (finite ? player.currentTime / player.duration * 100 : 0) + '%');
time.textContent = mediaClock(player.currentTime) + ' / ' + mediaClock(finite ? player.duration : 0);
- for (const b of actions.querySelectorAll('button')) b.title = b.textContent + ' ' + mediaClock(player.currentTime);
if (audio && !waveStarted && finite) {
waveStarted = true;
const published = waveformFromTags(ev, url);
if (published) draw(published);
else if (player.duration <= 1200 && !navigator.connection?.saveData) loadWaveform(url).then(draw);
}
- const items = ev ? moments(ev.id, url) : [];
- const next = items.map(x => x.id).join(',');
+ const items = ev && finite ? moments(ev.id, url) : [];
+ const next = items.map(x => x.id).join(',') + '@' + (finite ? player.duration : 0);
if (signature !== next) {
signature = next;
- marks.replaceChildren(...items.map(x => h('button', { type: 'button', class: 'media-mark btn-sm',
- title: x.text, onClick: () => { jump(x.moment.seconds); detail.textContent = x.text || x.icon; } }, mediaClock(x.moment.seconds) + ' ' + x.icon)));
+ marks.replaceChildren(...items.map(x => h('button', { type: 'button', class: 'media-mark', title: mediaClock(x.moment.seconds) + ' ' + x.text,
+ style: 'left:' + markAt(x.moment.seconds, player.duration) + '%',
+ onClick: e => { e.stopPropagation(); jump(x.moment.seconds); detail.textContent = mediaClock(x.moment.seconds) + ' ' + x.text; } }, glyph(x))));
}
};
// Fetching/decoding starts only once the user plays (or explicitly seeks).
for (const type of ['timeupdate', 'playing', 'seeked', 'durationchange', 'mediaactivity']) player.addEventListener(type, update);
- actions.hidden = true;
return box;
}
diff --git a/src/style.css b/src/style.css
index 6e1097a..4fbe96b 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1614,7 +1614,8 @@ body::after {
}
.mp-counts .i { font-size: 24px; line-height: 1; display: flex; }
.mp-counts .i svg { width: 28px; height: 28px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); }
-.mp-like.on { color: #ff4d6d; }
+/* yours: the heart goes red; the count stays white (a 🤣 of yours left only the number red) */
+.mp-like.on .i { color: #ff4d6d; }
.mp-like .i .cemoji { width: 28px; height: 28px; }
/* the emoji sheet over the media pager (z-index 200) */
.confirm-pop-backdrop.over-pager { z-index: 300; }
@@ -1961,12 +1962,10 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut
.call-log-pill svg { width: 16px; height: 16px; }
.chat-row.call-log.missed .call-log-pill { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); }
-/* Timestamp actions and the audio waveform share the player's lifetime. */
-.media-timeline { padding: 8px 0; white-space: normal; color: var(--text); }
-.media-moment-actions { gap: 6px; flex-wrap: wrap; align-items: center; }
-.media-moment-actions[hidden] { display: none; }
-.media-moment-actions > span { margin-right: auto; font-variant-numeric: tabular-nums; }
-.media-moment-actions button { font-size: 12px; min-height: 32px; }
+/* A post's audio/video timeline: the waveform (audio), and what was said,
+ reacted or zapped at a moment, sitting over the bar at that moment. */
+.media-timeline { padding: 24px 0 8px; white-space: normal; color: var(--text); }
+.media-time { font-variant-numeric: tabular-nums; }
.media-seek { position: relative; height: 42px; --played: 0%; }
.media-wave { position: absolute; inset: 3px 0; display: flex; align-items: center; gap: 2px;
background: linear-gradient(to right, var(--accent) var(--played), var(--line) var(--played)); }
@@ -1976,14 +1975,32 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut
.has-wave .media-wave::after { content: ''; position: absolute; inset: 0 0 0 var(--played); background: var(--bg, #fff); opacity: .55; pointer-events: none; }
.media-seek input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: .01; cursor: pointer; }
.media-seek:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
-.media-marks { display: flex; gap: 6px; overflow-x: auto; padding-top: 5px; }
-.media-mark { flex-shrink: 0; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.media-marks:empty { display: none; }
-.mp-slide > .media-timeline { position: absolute; left: 16px; right: 16px; bottom: calc(var(--mp-seek-lift) + 30px); z-index: 4;
- padding: 6px; border-radius: 8px; background: rgba(0,0,0,.7); color: #fff; display: none; }
-.media-pager.show .mp-cur > .media-timeline { display: block; }
-.mp-slide > .media-timeline .media-seek { display: none; }
-.media-pager.videos.show .mp-meta { padding-bottom: calc(var(--mp-seek-lift) + 130px); }
+.media-marks { position: absolute; left: 0; right: 0; top: -22px; height: 22px; pointer-events: none; }
+.media-mark, .mp-mark {
+ position: absolute; bottom: 0; transform: translateX(-50%); padding: 0 2px; margin: 0; border: 0; background: none;
+ font-size: 16px; line-height: 1; cursor: pointer; pointer-events: auto;
+}
+.media-mark .cemoji, .mp-mark .cemoji { width: 20px; height: 20px; vertical-align: bottom; }
+/* the pager's: above its seek bar, there when the bar is */
+.mp-marks { position: absolute; left: 0; right: 0; bottom: 10px; height: 26px; pointer-events: none; }
+.mp-mark { font-size: 20px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); }
+.mp-mark .cemoji { width: 24px; height: 24px; }
+.mp-seek-time { max-width: calc(100% - 16px); overflow: hidden; text-overflow: ellipsis; }
+.mp-seek.say .mp-seek-time { opacity: 1; }
+.mp-marks:not(:empty) ~ .mp-seek-time { bottom: 42px; }
+/* ...and a moment the clip plays through floats up off the bar */
+.mp-pop {
+ position: absolute; bottom: calc(var(--mp-seek-lift) + 12px); z-index: 4; transform: translateX(-50%);
+ font-size: 30px; line-height: 1; pointer-events: none; filter: drop-shadow(0 1px 3px rgba(0, 0, 0, .6));
+ animation: mp-pop 1.6s ease-out forwards;
+}
+.mp-pop .cemoji { width: 34px; height: 34px; }
+@keyframes mp-pop {
+ 0% { opacity: 0; transform: translate(-50%, 8px) scale(.6); }
+ 15% { opacity: 1; transform: translate(-50%, 0) scale(1.1); }
+ 100% { opacity: 0; transform: translate(-50%, -90px) scale(1); }
+}
+@media (prefers-reduced-motion: reduce) { .mp-pop { animation: none; opacity: 0; } }
.media-moment-detail { overflow-wrap: anywhere; max-height: 72px; overflow-y: auto; margin-top: 5px; }
.media-moment-detail:empty { display: none; }