diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 130 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/media-timeline.js | 38 | ||||
| -rw-r--r-- | src/style.css | 47 |
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; } |
