aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js127
1 files changed, 99 insertions, 28 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 54020bb..89f3bf4 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -4434,14 +4434,102 @@ export function messagesFeature(ctx) {
return m ? Math.max(0, parseInt(m[1], 10) || 0) : 0;
};
+ // ---- autoplay: a video plays, muted, while it is on screen --------------
+ // The way a timeline does it: a video starts by itself as it scrolls into
+ // view — muted, the only way a browser lets a page start one — pauses as
+ // it leaves, and a corner button turns the sound on. YouTube gets the same
+ // treatment: the player loads muted once its box is in view, and the
+ // button asks it to unmute over the iframe API. Data Saver keeps the old
+ // tap-to-play. One observer watches every player.
+ const autoplayOk = () => { try { return !(navigator.connection && navigator.connection.saveData); } catch { return true; } };
+ let playerWatch = null;
+ function watchPlayer(el, onIn, onOut) {
+ if (typeof IntersectionObserver === 'undefined' || !autoplayOk()) return;
+ if (!playerWatch) playerWatch = new IntersectionObserver((entries) => {
+ for (const en of entries) {
+ const el2 = en.target;
+ if (!el2.isConnected) { playerWatch.unobserve(el2); continue; }
+ if (en.isIntersecting && en.intersectionRatio >= 0.5) { if (el2._onIn) el2._onIn(); } else if (el2._onOut) el2._onOut();
+ }
+ }, { threshold: [0, 0.5] });
+ el._onIn = onIn; el._onOut = onOut;
+ playerWatch.observe(el);
+ }
+ // The corner button: what it says is the state it would switch to.
+ function soundBtn(onClick) {
+ const btn = h('button', { class: 'vid-sound', type: 'button', onClick: (e) => { e.stopPropagation(); e.preventDefault(); onClick(); } });
+ btn.setSound = (on) => {
+ btn.classList.toggle('on', !!on);
+ btn.textContent = (on ? '\u{1F50A} ' : '\u{1F507} ') + (on ? t('videoMute') : t('videoUnmute'));
+ btn.setAttribute('aria-label', on ? t('videoMute') : t('videoUnmute'));
+ btn.title = on ? t('videoMute') : t('videoUnmute');
+ };
+ btn.setSound(false);
+ return btn;
+ }
+ // An inline video, in a box with the sound button; plays on its own while
+ // on screen. Once it has played, the box is the viewer's (no morph).
+ function videoNode(url, { stable = false } = {}) {
+ const v = h('video', { src: url, class: 'note-video', controls: true,
+ preload: autoplayOk() ? 'auto' : 'metadata', playsinline: true, muted: true,
+ style: stable ? 'width:100%;aspect-ratio:16/9;object-fit:contain' : undefined,
+ onError: (e) => { if (!stable) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } });
+ v.muted = true; // the property, not just the attribute: a script-made element autoplays only muted
+ const box = h('div', { class: 'note-video-box' }, v);
+ const btn = soundBtn(() => {
+ v.muted = !v.muted;
+ btn.setSound(!v.muted);
+ if (!v.muted) { box._skipMorph = true; if (v.paused) v.play().catch(() => {}); }
+ });
+ v.onvolumechange = () => btn.setSound(!v.muted); // the native control's own mute keeps the button honest
+ box.append(btn);
+ watchPlayer(v,
+ () => { if (v.paused) v.play().then(() => { box._skipMorph = true; }).catch(() => {}); },
+ () => { if (!v.paused) v.pause(); });
+ return box;
+ }
+
// The still, with a play button over it, and the player itself only once
- // it's tapped. A feed of ten videos would otherwise load ten YouTube
- // players — every one of them telling Google what you're scrolling past
- // before you've decided to watch anything. One tap, and it plays in place.
+ // it's tapped — or, with autoplay, once the box scrolls into view, muted.
+ // A feed of ten videos would otherwise load ten YouTube players at once,
+ // every one of them telling Google what you're scrolling past; loading on
+ // view (and never with sound uninvited) keeps that to what you look at.
function youtubeEmbed(url, vid) {
const start = ytStart(url);
- const src = 'https://www.youtube-nocookie.com/embed/' + vid
- + '?autoplay=1&rel=0' + (start ? '&start=' + start : '');
+ const embedSrc = (muted) => 'https://www.youtube-nocookie.com/embed/' + vid
+ + '?autoplay=1&rel=0&playsinline=1&enablejsapi=1' + (muted ? '&mute=1' : '') + (start ? '&start=' + start : '')
+ + (typeof location !== 'undefined' ? '&origin=' + encodeURIComponent(location.origin) : '');
+ const src = embedSrc(false);
+ // the iframe API listens for commands once told someone is listening
+ const post = (box, func) => {
+ const f = box.querySelector('iframe');
+ try { if (f && f.contentWindow) f.contentWindow.postMessage(JSON.stringify({ event: 'command', func, args: [] }), '*'); } catch {}
+ };
+ const load = (box, muted) => {
+ if (!box || box.dataset.playing) return;
+ box.dataset.playing = '1';
+ // The page repaints in the background all the time (a zap count,
+ // a profile landing, the thirty-second sync), and every repaint
+ // rebuilds this box as poster + play button and patches the live
+ // one into that shape — which threw the playing iframe out a few
+ // seconds into every video. Once playing, the box is the viewer's
+ // and the morph leaves it exactly as it stands.
+ box._skipMorph = true;
+ box.textContent = '';
+ const f = document.createElement('iframe');
+ f.src = muted ? embedSrc(true) : src;
+ f.title = 'YouTube';
+ f.allow = 'accelerometer; autoplay; encrypted-media; picture-in-picture; web-share';
+ f.referrerPolicy = 'strict-origin-when-cross-origin';
+ f.allowFullscreen = true;
+ f.onload = () => { try { f.contentWindow.postMessage(JSON.stringify({ event: 'listening', id: vid }), '*'); } catch {} };
+ box.append(f);
+ if (muted) {
+ let on = false;
+ const btn = soundBtn(() => { on = !on; btn.setSound(on); post(box, on ? 'unMute' : 'mute'); if (on) post(box, 'playVideo'); });
+ box.append(btn);
+ }
+ };
const frame = h('div', { class: 'yt-embed' },
h('img', {
class: 'yt-poster', loading: 'lazy', alt: '',
@@ -4455,28 +4543,17 @@ export function messagesFeature(ctx) {
e.stopPropagation();
const box = e.currentTarget.parentElement;
if (!box || box.dataset.playing) return;
- box.dataset.playing = '1';
- // The page repaints in the background all the time (a zap count,
- // a profile landing, the thirty-second sync), and every repaint
- // rebuilds this box as poster + play button and patches the live
- // one into that shape — which threw the playing iframe out a few
- // seconds into every video. Once playing, the box is the viewer's
- // and the morph leaves it exactly as it stands.
- box._skipMorph = true;
- box.textContent = '';
- const f = document.createElement('iframe');
- f.src = src;
- f.title = 'YouTube';
- f.allow = 'accelerometer; autoplay; encrypted-media; picture-in-picture; web-share';
- f.referrerPolicy = 'strict-origin-when-cross-origin';
- f.allowFullscreen = true;
- box.append(f);
+ load(box, false); // a tap means sound
},
}, h('span', { style: 'display:flex', html: '<svg width="30" height="30" viewBox="0 0 24 24" fill="currentColor" style="display:block"><path d="M8 5v14l11-7z"/></svg>' })),
h('a', {
class: 'yt-open', href: url, target: '_blank', rel: 'noopener noreferrer',
title: t('openInYouTube'), onClick: (e) => e.stopPropagation(),
}, '\u2197'));
+ // in view: load muted the first time, resume after; out of view: pause
+ watchPlayer(frame,
+ () => { if (!frame.dataset.playing) load(frame, true); else if (frame._pausedByUs) { frame._pausedByUs = false; post(frame, 'playVideo'); } },
+ () => { if (frame.dataset.playing) { frame._pausedByUs = true; post(frame, 'pauseVideo'); } });
return frame;
}
@@ -4545,13 +4622,7 @@ export function messagesFeature(ctx) {
// is, which beats guessing from the filename.
function urlNode(url, { label = null, isImage = false } = {}) {
if (/\.(mp4|webm|mov|m4v)(\?[^\s]*)?$/i.test(url)) {
- // metadata-only preload: the poster frame paints, nothing streams
- // until the viewer presses play
- const stable = !!feedPaint;
- return h('video', { src: url, class: 'note-video', controls: true,
- preload: 'metadata', playsinline: true,
- style: feedPaint ? 'width:100%;aspect-ratio:16/9;object-fit:contain' : undefined,
- onError: (e) => { if (!stable) e.target.style.display = 'none'; } });
+ return videoNode(url, { stable: !!feedPaint });
}
if (isImage || /\.(png|jpe?g|gif|webp|avif)(\?[^\s]*)?$/i.test(url)) {
const size = feedPaint ? feedMedia(url) : null;