diff options
| author | Adam Soltys <[email protected]> | 2026-09-23 23:00:31 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-23 23:00:31 -0700 |
| commit | f6fd9f998831330e3a8819e294cdd8d15c1a87f6 (patch) | |
| tree | 3d8399d75efffc9c9bd091280984c8776682cd05 | |
| parent | 75679fdd5d24d36e3c54316e88ccefcf954afc82 (diff) | |
| download | coinosv3-f6fd9f998831330e3a8819e294cdd8d15c1a87f6.tar.gz coinosv3-f6fd9f998831330e3a8819e294cdd8d15c1a87f6.zip | |
Videos play by themselves, muted, while on screen — with an Unmute button; YouTube too.
An inline video starts 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; the native controls' own mute keeps the button honest.
A YouTube box loads its player muted once in view (enablejsapi, so the
same button can unmute it over the iframe API) and pauses out of view; a
tap on the poster still means sound. One IntersectionObserver watches
every player. Data Saver keeps tap-to-play.
Tests adapted to the feed's new admission rule (a row waits for its
author's face and pictures): the markdown and post-actions tests seed the
poster's profile and serve their pictures locally; a picture that cannot
be decoded is now a link, by design.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
| -rw-r--r-- | src/features/messages.js | 127 | ||||
| -rw-r--r-- | src/i18n.js | 2 | ||||
| -rw-r--r-- | src/style.css | 10 | ||||
| -rw-r--r-- | tools/markdown-note-test.js | 19 | ||||
| -rw-r--r-- | tools/post-actions-test.js | 5 | ||||
| -rw-r--r-- | tools/video-autoplay-test.js | 96 |
6 files changed, 226 insertions, 33 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; diff --git a/src/i18n.js b/src/i18n.js index 3307c19..46f26f5 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -219,6 +219,8 @@ const STR = { zapTallyTitle: '{n} sats zapped', close: 'Close', playVideo: 'Play', + videoUnmute: 'Unmute', + videoMute: 'Mute', openInYouTube: 'Open on YouTube', accountsThisOne: 'signed in', worthThen: 'Worth then', diff --git a/src/style.css b/src/style.css index fc1f384..311f6a2 100644 --- a/src/style.css +++ b/src/style.css @@ -711,6 +711,16 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; } .lightbox-x:hover { background: rgba(255, 255, 255, .24); } @keyframes lightbox-in { from { opacity: 0; } to { opacity: 1; } } .note-video { display: block; max-width: 100%; max-height: 360px; border-radius: 10px; margin-top: 6px; background: #000; } +/* a video plays by itself, muted, while it is on screen; the corner button + turns the sound on (see autoplay in messages.js) */ +.note-video-box { position: relative; display: block; max-width: 100%; margin-top: 6px; } +.note-video-box .note-video { margin-top: 0; } +.vid-sound { + position: absolute; top: 8px; left: 8px; z-index: 2; margin: 0; padding: 4px 10px; border: 0; + border-radius: 999px; background: rgba(18, 18, 18, .7); color: #fff; font-size: 13px; line-height: 1.3; + cursor: pointer; display: inline-flex; align-items: center; gap: 5px; +} +.vid-sound:hover { background: rgba(18, 18, 18, .9); } /* A quoted note, inside the post that quotes it: its own little card, so the two voices don't run together. Context, not another post to act on — the action row stops at the outer post. */ diff --git a/tools/markdown-note-test.js b/tools/markdown-note-test.js index 2c5a539..e27336f 100644 --- a/tools/markdown-note-test.js +++ b/tools/markdown-note-test.js @@ -21,8 +21,11 @@ const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' const SK = generateSecretKey(); const AUTHOR = getPublicKey(SK); -const IMG = 'https://coinos.io/punks/7.webp'; -const NOEXT = 'https://example.invalid/media/9f2a1c'; +// served below: a picture the feed can actually decode — a row shows a +// picture it has decoded and links the ones it could not +const IMG = 'http://localhost:5298/pic.png'; +const NOEXT = 'http://localhost:5298/media/9f2a1c'; +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'); // the shape the real post had: the paren, then a newline, then the URL const CASES = [ ['md-image', 'here it is\n\nthat was it'], @@ -36,7 +39,12 @@ const notes = CASES.map(([, content], i) => finalizeEvent({ kind: 1, created_at: Math.floor(Date.now() / 1000) - i * 60, tags: [], content }, SK)); const html = await buildHtml({ minify: true, pwa: false }); -const server = Bun.serve({ port: 5298, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const server = Bun.serve({ port: 5298, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); // the author's face, so rows are admitted at once + if (path === '/pic.png' || path === '/media/9f2a1c') return new Response(PNG, { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); const page = await browser.newPage(); const errs = []; @@ -60,13 +68,16 @@ try { await page.evaluate(([k, pk, ns]) => { localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Markdown Poster', t: Date.now() } })); }, [cacheKeyFor(mn + '\n'), AUTHOR, notes]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); await sleep(1000); await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); - await sleep(4000); + // rows are admitted once their pictures have been tried (a dead host takes the deadline) + for (let i = 0; i < 60; i++) { if ((await page.evaluate(() => document.querySelectorAll('.notes-feed > .row').length)) >= 6) break; await sleep(250); } + await sleep(500); const rows = await page.evaluate(() => [...document.querySelectorAll('.notes-feed > .row')].map((r) => { const b = r.querySelector('.note-text'); diff --git a/tools/post-actions-test.js b/tools/post-actions-test.js index b7d56e6..d8c4c84 100644 --- a/tools/post-actions-test.js +++ b/tools/post-actions-test.js @@ -52,13 +52,16 @@ try { localStorage.setItem(k + ':feedNotes', JSON.stringify([ { id: nid, pubkey: pk, kind: 1, created_at: Math.floor(Date.now()/1000), content: 'More flowers http://localhost:5258/pic.png', tags: [] }, ])); + // a known face: a row is admitted once its author's picture has been tried + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Flower Poster', t: Date.now() } })); }, [base, AUTHOR, NOTE_ID]); await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); await sleep(1000); await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => /feed/i.test(n.textContent)); if (e) e.click(); }); - await sleep(2500); + for (let i = 0; i < 48; i++) { if (await page.$('.note-act')) break; await sleep(250); } + await sleep(600); const acts = await page.evaluate(() => [...document.querySelectorAll('.note-act')].map((b) => b.getAttribute('aria-label'))); check('every action sits under the post', acts.slice(0, 4).join(',') === 'Reply,Boost,Quote,Like', acts.join(',') || 'none'); diff --git a/tools/video-autoplay-test.js b/tools/video-autoplay-test.js new file mode 100644 index 0000000..2665ac5 --- /dev/null +++ b/tools/video-autoplay-test.js @@ -0,0 +1,96 @@ +// Autoplay: a video in a post plays by itself, muted, while it is on screen, +// with a corner Unmute button; it pauses once scrolled away. A YouTube box +// loads its player muted when in view (with the same button) — nothing is +// loaded for a box that never comes into view. Set CLIP to a webm to serve; +// the default is made with ffmpeg on the fly. +// Run: bun tools/video-autoplay-test.js +import puppeteer from 'puppeteer-core'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { existsSync } from 'node:fs'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const SK = generateSecretKey(); +const AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const CLIP = process.env.CLIP || '/tmp/coinos-autoplay-clip.webm'; +if (!existsSync(CLIP)) { + const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=64x64:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=2', '-t', '2', '-c:v', 'libvpx', '-b:v', '50k', '-c:a', 'libvorbis', CLIP]); + if (p.exitCode !== 0) { console.log('ffmpeg failed; set CLIP=<a webm>'); process.exit(1); } +} +const seeded = [ + post('watch this http://localhost:5301/clip.webm', 100), + ...Array.from({ length: 6 }, (_, i) => post('filler post number ' + (i + 1) + ' to push the next one down the page ' + 'lorem ipsum '.repeat(40), 200 + i * 10)), + post('and this one https://youtu.be/dQw4w9WgXcQ', 300), +]; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5301, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path === '/clip.webm') return new Response(Bun.file(CLIP), { headers: { 'content-type': 'video/webm' } }); + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +// YouTube itself is never contacted: what matters is what the box asks for +await page.setRequestInterception(true); +const ytRequests = []; +page.on('request', (r) => { if (/youtube|ytimg/.test(r.url())) { ytRequests.push(r.url()); r.abort(); } else r.continue(); }); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; }; + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5301/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } })); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); }); + await sleep(800); + await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => (n.querySelector('.chat-name') || {}).textContent === 'Feed'); if (e) e.click(); }); + + const players = () => ytRequests.filter((u) => /youtube-nocookie\.com\/embed/.test(u)).length; // the poster still comes from ytimg, as before + console.log('\n[a video in a post]'); + check('the post shows its video in a box with a sound button', await waitFor(() => !!document.querySelector('.note-video-box video') && !!document.querySelector('.note-video-box .vid-sound'), 20000)); + check('it is muted, and the button offers Unmute', await page.evaluate(() => { const v = document.querySelector('.note-video-box video'); const b = document.querySelector('.note-video-box .vid-sound'); return v.muted && /unmute/i.test(b.textContent); })); + check('on screen, it plays by itself', await waitFor(() => { const v = document.querySelector('.note-video-box video'); return v && !v.paused && v.currentTime > 0; }, 8000), + JSON.stringify(await page.evaluate(() => { const v = document.querySelector('.note-video-box video'); return v && { paused: v.paused, t: v.currentTime, err: v.error && v.error.code, ready: v.readyState }; }))); + await page.evaluate(() => document.querySelector('.note-video-box .vid-sound').click()); await sleep(200); + check('Unmute turns the sound on and offers Mute', await page.evaluate(() => { const v = document.querySelector('.note-video-box video'); const b = document.querySelector('.note-video-box .vid-sound'); return !v.muted && /^\S+ mute$/i.test(b.textContent.trim()); })); + await page.evaluate(() => window.scrollTo(0, 1000)); await sleep(700); + check('scrolled away, it pauses', await waitFor(() => { const v = document.querySelector('.note-video-box video'); return v && v.paused; }, 4000)); + + console.log('\n[a YouTube link]'); + const ytBefore = players(); + check('a box further down has not loaded a player', ytBefore === 0 && await page.evaluate(() => { const b = document.querySelector('.yt-embed'); return b && b.getBoundingClientRect().top > window.innerHeight; }), String(ytBefore)); + check('a box stands in for the player', await page.evaluate(() => !!document.querySelector('.yt-embed'))); + await page.evaluate(() => document.querySelector('.yt-embed').scrollIntoView({ block: 'center' })); await sleep(900); + const src = await waitFor(() => !!document.querySelector('.yt-embed iframe'), 6000) ? await page.evaluate(() => document.querySelector('.yt-embed iframe').src) : ''; + check('in view, the player loads muted with autoplay and the API on', /youtube-nocookie\.com\/embed\/dQw4w9WgXcQ/.test(src) && /autoplay=1/.test(src) && /mute=1/.test(src) && /enablejsapi=1/.test(src) && /origin=/.test(src), src); + check('...with the sound button over it', await page.evaluate(() => !!document.querySelector('.yt-embed .vid-sound'))); + await sleep(500); + check('...and only then was a player asked for', ytBefore === 0 && players() > 0, ytBefore + ' → ' + players()); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ videos play by themselves, muted, while on screen' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
