diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 178 | ||||
| -rw-r--r-- | src/style.css | 23 |
2 files changed, 179 insertions, 22 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 7abbb93..648f22d 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7507,6 +7507,7 @@ export function messagesFeature(ctx) { else (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {}); return; } + if (openClip(v, url)) return; // a clip still idle (autoplaying, no controls) becomes the reader's wokeClips.add(url); const box = v.closest('.note-video-box'); @@ -7520,7 +7521,11 @@ export function messagesFeature(ctx) { }).catch(() => {}); } if (typeof document !== 'undefined') document.addEventListener('fullscreenchange', () => { - if (!fsElement()) { try { screen.orientation?.unlock?.(); } catch {} } + if (fsElement()) return; + try { screen.orientation?.unlock?.(); } catch {} + // out of the pager's full screen some other way (Back on Android, Escape + // on a computer): out of the pager too, in the one step + if (pagerFs) { pagerFs = false; if (pager) dismissPager(); } }); function videoNode(url, { stable = false } = {}) { const loud = unmutedClips.has(url); @@ -11611,37 +11616,81 @@ export function messagesFeature(ctx) { } return out; } - // The feed's pictures in reading order — every one of a post before the - // next post's — for the pager opened from a picture in the feed: the - // feed's own list (its order, its moderation), not the media index. - function feedImageItems(c) { + // The feed's pictures (or clips) in reading order — every one of a post + // before the next post's — for the pager opened from one in the feed: the + // feed's own list (its order, its moderation), not the media index. Clips + // of every shape, not only the tall ones the Videos view flips through. + function feedMediaItems(c, kind) { const out = []; const list = c.winList ? c.winList() : c.notes; for (const row of list) { const ev = row.kind === REPOST ? repostInner(row) || row : row; if (modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue; - for (const url of noteMediaUrls(ev.content)) { - if (url.startsWith('https://i.ytimg.com/')) continue; - out.push({ ev, url, row }); - } + if (kind === 'videos') learnVideoMeta(ev); + const urls = kind === 'videos' ? noteVideoUrls(ev.content) + : noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/')); + for (const url of urls) out.push({ ev, url, row }); } return out; } + // The feed row a tapped picture or clip sits in, when the feed itself is + // the page (not a thread, a profile or a chat). + function feedRowOf(el) { + const inFeed = ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && feed && !feed.stopped && feed.winList; + return inFeed && el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null; + } // A picture tapped in the feed opens in the media pager, on that picture, // with the feed's other pictures a swipe (or an arrow) away, the way the // media grid's are. On any other page — a thread, a profile, a chat — the // picture opens alone, as before. function openPicture(url, e) { try { - const inFeed = ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && feed && !feed.stopped && feed.winList; - const rowId = e && e.target && e.target.closest ? e.target.closest('.notes-feed > [data-key]')?.getAttribute('data-key') : null; - if (inFeed && rowId) { - const it = feedImageItems(feed).find((x) => x.row.id === rowId && x.url === url); + const rowId = feedRowOf(e && e.target); + if (rowId) { + const it = feedMediaItems(feed, 'images').find((x) => x.row.id === rowId && x.url === url); if (it) { openMediaPager('images', mediaKey(it), { fromFeed: true }); return; } } } catch {} ctx.openImage && ctx.openImage(url); } + // A clip tapped in the feed opens in the pager the same way, over the + // feed's clips, picking up where it was. The pager takes the whole screen + // (what the player's own full screen did), and a wide clip turns the phone + // on its side — for that clip; past it the screen follows the phone. + function openClip(v, url) { + try { + const rowId = feedRowOf(v); + if (!rowId) return false; + const it = feedMediaItems(feed, 'videos').find((x) => x.row.id === rowId && x.url === url); + if (!it) return false; + tappedClips.add(url); // asked for by hand: no tap-to-play cover + const d = videoDims.get(url) || videoInfo.get(url) || {}; + const w = v.videoWidth || d.w || d.width, hh = v.videoHeight || d.h || d.height; + const at = v.currentTime || 0; + v.pause(); + openMediaPager('videos', mediaKey(it), { fromFeed: true, at }); + pagerFullscreen(!!(w && hh && w > hh)); + return true; + } catch { return false; } + } + let pagerFs = false; // the pager put the page full screen (and so takes it back out) + function pagerFullscreen(wide) { + const d = document.documentElement; + const go = d.requestFullscreen || d.webkitRequestFullscreen; + if (!go || fsElement()) return; + Promise.resolve(go.call(d, { navigationUI: 'hide' })).then(() => { + if (!ui.mediaPager) { exitFs(); return; } + pagerFs = true; + if (wide) screen.orientation?.lock?.('landscape').catch(() => {}); + }).catch(() => {}); + } + const exitFs = () => { try { (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {}); } catch {} }; + function pagerUnFullscreen() { + if (!pagerFs) return; + pagerFs = false; + try { screen.orientation?.unlock?.(); } catch {} + if (fsElement() === document.documentElement) exitFs(); + } // The words of the post, without the links to the media it carries. function mediaCaption(ev) { let s = String(ev.content || ''); @@ -11767,10 +11816,11 @@ export function messagesFeature(ctx) { } } - // `fromFeed`: opened from a picture in the feed (its pictures, in the - // feed's order), rather than from the media grid - function openMediaPager(kind, start, { fromFeed = false } = {}) { - ui.mediaPager = { kind, start, feed: curFeedId, fromFeed }; + // `fromFeed`: opened from a picture or clip in the feed (its pictures or + // clips, in the feed's order), rather than from the media grid; `at`: where + // the clip it opened on was, so it carries on from there + function openMediaPager(kind, start, { fromFeed = false, at = 0 } = {}) { + ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, at }; render(); } const closeMediaPager = () => ctx.goBack(() => { ui.mediaPager = null; }); @@ -11805,7 +11855,7 @@ export function messagesFeature(ctx) { function syncMediaPager() { const want = ui.mediaPager && ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread ? ui.mediaPager : null; if (!want) { if (pager) dropPager(); return; } - if (pager && pager.kind !== want.kind) dropPager(); + if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed)) dropPager(); if (!pager) buildPager(want); else growPager(); } @@ -11814,6 +11864,9 @@ export function messagesFeature(ctx) { pager = null; try { p.io.disconnect(); p.near.disconnect(); } catch {} clearInterval(p.tick); + cancelAnimationFrame(p.raf); + window.removeEventListener('resize', p.onResize); + pagerUnFullscreen(); for (const v of p.el.querySelectorAll('video')) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch {} } p.el.remove(); document.documentElement.classList.remove('no-scroll'); @@ -11876,9 +11929,59 @@ 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)); } return slide; } + // A clip's own seek bar: a thin line along the bottom that fills as it + // plays, thicker with the details up or under a finger. Dragging it scrubs + // (the clip holds still meanwhile, and the time shows); the drag is the + // bar's alone, never the pager's swipe or its tap. + const clock = (t) => { t = Math.max(0, Math.floor(t || 0)); const m = Math.floor(t / 60), x = t % 60; return m + ':' + String(x).padStart(2, '0'); }; + function seekBar(v) { + 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 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); + }; + const to = (f) => { try { v.currentTime = f * v.duration; } catch {} }; + bar.addEventListener('pointerdown', (e) => { + if (!(v.duration > 0 && isFinite(v.duration))) return; + e.stopPropagation(); e.preventDefault(); + try { bar.setPointerCapture(e.pointerId); } catch {} + bar._drag = { playing: !v.paused, id: e.pointerId }; + bar.classList.add('drag'); + v.pause(); + const f = frac(e); show(f); to(f); + }); + bar.addEventListener('pointermove', (e) => { + if (!bar._drag || bar._drag.id !== e.pointerId) return; + e.stopPropagation(); + const f = frac(e); show(f); + if (v.fastSeek) { try { v.fastSeek(f * v.duration); } catch { to(f); } } else to(f); + }); + const end = (e) => { + const d = bar._drag; + if (!d || d.id !== e.pointerId) return; + bar._drag = null; + bar.classList.remove('drag'); + if (e.type === 'pointerup') to(frac(e)); + if (d.playing) v.play().catch(() => {}); + }; + bar.addEventListener('pointerup', end); + bar.addEventListener('pointercancel', end); + bar.addEventListener('click', (e) => e.stopPropagation()); + bar._paint = () => { + if (bar._drag) return; + const ok = v.duration > 0 && isFinite(v.duration); + bar.classList.toggle('ready', ok); + if (ok) show(v.currentTime / v.duration); + }; + return bar; + } function pagerAttach(slide) { const m = slide.querySelector('.mp-media'); if (m && !m.getAttribute('src')) m.setAttribute('src', m.tagName === 'VIDEO' ? playSrc(m.getAttribute('data-src')) : m.getAttribute('data-src')); @@ -11959,6 +12062,13 @@ export function messagesFeature(ctx) { if (/#t=[\d.]+$/.test(v.getAttribute('src') || '')) v.setAttribute('src', playSrc(v.getAttribute('data-src'))); // the still's src: from the start now v.preload = 'auto'; pagerAttach(slide); + // the clip the pager opened on carries on from where it was in the feed + if (pager && pager.at && slide.getAttribute('data-k') === pager.start) { + const at = pager.at; + pager.at = 0; + if (v.readyState >= 1) v.currentTime = at; + else v.addEventListener('loadedmetadata', () => { try { v.currentTime = at; } catch {} }, { once: true }); + } v.muted = !pagerLoud; v.play().catch((err) => { // sound needs a tap the browser counted: play quietly, the button says so @@ -11988,7 +12098,17 @@ export function messagesFeature(ctx) { h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null)); // a tap shows (or hides) who posted it and what they said el.addEventListener('click', (e) => { - if (e.target.closest('button, a, .mp-caption')) return; + if (e.target.closest('button, a, .mp-caption, .mp-seek')) return; + if (pager && pager.fromFeed && video) { + // A clip opened from the feed: a tap pauses it and brings up who + // posted it, the ×, the seek bar to grab; another plays it on. + const v = pager.cur && pager.cur.querySelector('video.mp-media'); + if (v && !v.paused) { v.pause(); el.classList.add('show'); pagerCounts(pager.cur); return; } + el.classList.remove('show'); + if (v && v.getAttribute('src')) v.play().catch(() => {}); + else if (pager.cur) playSlide(pager.cur); + return; + } if (pager && pager.fromFeed) { // Opened from a post in the feed: a tap on the picture that opened // it closes it, as a full-screen picture always has. Once the reader @@ -12002,7 +12122,7 @@ export function messagesFeature(ctx) { el.classList.toggle('show'); if (el.classList.contains('show')) pagerCounts(pager && pager.cur); }); - pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, start: want.start }; + pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, start: want.start, at: want.at || 0 }; pager.near = new IntersectionObserver((entries) => { for (const e of entries) { if (!e.isIntersecting) continue; @@ -12013,7 +12133,12 @@ export function messagesFeature(ctx) { pager.io = new IntersectionObserver((entries) => { for (const e of entries) { if (!e.isIntersecting || e.intersectionRatio < 0.6 || !pager) continue; - if (pager.cur && pager.cur !== e.target) { const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause(); } + if (pager.cur && pager.cur !== e.target) { + const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause(); + // past the clip it opened on, the screen follows the phone again + if (pagerFs) { try { screen.orientation?.unlock?.(); } catch {} } + if (pager.fromFeed && video) el.classList.remove('show'); + } pager.cur = e.target; playSlide(e.target); pagerCounts(e.target); @@ -12027,6 +12152,15 @@ export function messagesFeature(ctx) { document.documentElement.classList.add('no-scroll'); const first = [...scroller.children].find((s) => s.getAttribute('data-k') === want.start) || scroller.firstChild; if (first) scroller.scrollTop = first.offsetTop; + // the clip on screen's seek bar, every frame (timeupdate comes a few times a second) + if (video) { + const p = pager; + const frame = () => { if (pager !== p) return; p.cur?.querySelector('.mp-seek')?._paint?.(); p.raf = requestAnimationFrame(frame); }; + p.raf = requestAnimationFrame(frame); + } + // turned on its side (or back), the slides change height: stay on this one + pager.onResize = () => { if (pager && pager.cur) pager.scroller.scrollTop = pager.cur.offsetTop; }; + window.addEventListener('resize', pager.onResize); // new posts land in the feed while the pager is open; counts move pager.tick = setInterval(() => { if (pager) { growPager(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500); } @@ -12035,7 +12169,7 @@ export function messagesFeature(ctx) { function growPager() { if (!pager) return; const c = feedNow(); - for (const it of (pager.fromFeed ? feedImageItems(c) : mediaItems(c, pager.kind))) { + for (const it of (pager.fromFeed ? feedMediaItems(c, pager.kind) : mediaItems(c, pager.kind))) { const k = mediaKey(it); if (pager.keys.has(k)) continue; pager.keys.add(k); diff --git a/src/style.css b/src/style.css index 0a1562d..9b7d6a0 100644 --- a/src/style.css +++ b/src/style.css @@ -1528,6 +1528,29 @@ body::after { .media-pager.quiet .mp-top .mp-x { opacity: 0; pointer-events: none; } .media-pager.quiet .mp-top .mp-sound { pointer-events: auto; } .media-pager.quiet.show .mp-top .mp-x { opacity: 1; pointer-events: auto; } +/* a clip's seek bar: a thin line along the bottom, thicker with the details + up or under a finger; the strip above it is the finger's (no swipe there) */ +.mp-seek { + position: absolute; left: 0; right: 0; bottom: env(safe-area-inset-bottom, 0px); z-index: 3; height: 28px; + display: flex; align-items: flex-end; touch-action: none; cursor: pointer; visibility: hidden; +} +.mp-seek.ready { visibility: visible; } +.mp-seek-track { position: relative; width: 100%; height: 3px; background: rgba(255, 255, 255, .28); transition: height .15s; } +.mp-seek-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: #fff; } +.mp-seek-fill::after { + content: ''; position: absolute; right: -7px; top: 50%; width: 14px; height: 14px; margin-top: -7px; + border-radius: 50%; background: #fff; opacity: 0; transition: opacity .15s; +} +.media-pager.show .mp-seek-track, .mp-seek.drag .mp-seek-track { height: 5px; } +.media-pager.show .mp-seek-fill::after, .mp-seek.drag .mp-seek-fill::after { opacity: 1; } +.mp-seek-time { + position: absolute; left: 50%; bottom: 22px; transform: translateX(-50%); padding: 3px 8px; border-radius: 6px; + font-size: 13px; font-variant-numeric: tabular-nums; white-space: nowrap; background: rgba(0, 0, 0, .55); + opacity: 0; pointer-events: none; transition: opacity .12s; +} +.mp-seek.drag .mp-seek-time { opacity: 1; } +/* the details sit clear of the seek bar */ +.media-pager.videos .mp-meta { padding-bottom: calc(max(16px, env(safe-area-inset-bottom)) + 22px); } /* a clip that waits for a tap: what it costs, over its (blank or still) box */ .vid-heavy { |
