diff options
| author | Adam Soltys <[email protected]> | 2026-10-20 12:15:32 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-20 12:15:32 -0700 |
| commit | 1e0d551daae8d09f86dbf54ab67bf52610c0c9ac (patch) | |
| tree | 7e118cc5a49a8308c77b17cbd291012f45cf0875 /src | |
| parent | 84b6a68000c942e17bfe75c1493eddad7ceb5b78 (diff) | |
| download | coinosv3-1e0d551daae8d09f86dbf54ab67bf52610c0c9ac.tar.gz coinosv3-1e0d551daae8d09f86dbf54ab67bf52610c0c9ac.zip | |
Timed reactions sit on the seek bar at their moment, and the usual buttons carry the time
The "at this time" reply/like/zap buttons are gone, and with them the panel they made over the pager. A reply, like or zap made from a post's own buttons (the feed's, or the pager's ♥ ⚡ and post) while its clip has played is about that moment; the reply box says so and its × makes it about the whole post. What was said, reacted or zapped at a moment sits over the bar at that moment (a custom :emoji: as its picture, from the reaction's own tag; it was showing as :cb-rofl: text), a tap goes there and says who and what, and in the pager it floats up off the bar as the clip plays past. Your reaction no longer turns the count red, only the heart.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
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; } |
