aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js178
-rw-r--r--src/style.css23
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 {