Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions tests/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,54 @@ await test('the studio speaks Italian when the system does', async () => {
await itCtx.close();
});

// Some browser extensions (KDE Plasma Integration, on by default on many
// Linux desktops) briefly insert and remove a detached <video>/<audio> when it
// starts playing; Chromium then pauses it and play() rejects with "the media
// was removed from the document" (issue #2). This mimics that behaviour.
const DETACHED_MEDIA_MOVER = `(() => {
const create = Document.prototype.createElement;
Document.prototype.createElement = function (...args) {
const el = create.apply(this, args);
if (typeof args[0] === 'string' && /^(audio|video)$/i.test(args[0])) {
el.addEventListener('play', () => {
if (!el.isConnected) {
document.documentElement.appendChild(el);
el.remove();
}
}, { once: true });
}
return el;
};
})();`;

await test('a video clip plays even with an extension that moves detached media', async () => {
const clip = join(work, 'clip.mp4');
execFileSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=30', '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-t', '4', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', clip]);
const cctx = await browser.newContext({ permissions: ['camera', 'microphone'], locale: 'en-US' });
await cctx.addInitScript(DETACHED_MEDIA_MOVER);
const page = await cctx.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto(server.studio);
await waitFor(page, () => window.streamFree?.people.get('host'));
await page.click('button[aria-label="Video clips"]');
await page.setInputFiles('#dialog input[type=file]', clip);
await page.waitForSelector('#dialog .asset-list li button:has-text("Play")');
await page.click('#dialog .asset-list li button:has-text("Play")');
await waitFor(page, () => window.streamFree.people.has('media'));
await sleep(1000);
const state = await page.evaluate(() => {
const v = document.querySelector('.media-host video');
return { inPage: !!v, playing: !!v && !v.paused, time: v?.currentTime || 0 };
});
assert.ok(state.inPage && state.playing && state.time > 0.3, `clip state ${JSON.stringify(state)}`);
const toasts = await page.evaluate(() => [...document.querySelectorAll('.toast')].map((t) => t.textContent).join(' | '));
assert.ok(!toasts.includes('Cannot play'), toasts);
assert.deepEqual(errors, []);
await cctx.close();
});

await test('every interface string has an Italian translation', async () => {
const { IT } = await import('../../web/js/i18n.js');
const { readFileSync } = await import('node:fs');
Expand Down
6 changes: 6 additions & 0 deletions web/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -335,3 +335,9 @@ details.guide p { margin: 6px 0 0; font-size: 13px; }
[data-panel="left"] .sidebar { order: 0; border-right: 0; }
.invite-share { flex-direction: column; }
}

/* Invisible home for media elements used only for their sound or frames
(see keepInPage in util.js). Kept on screen at 1 px, not display: none, so
no browser treats them as hidden and throttles them. */
.media-host { position: fixed; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; opacity: 0; pointer-events: none; z-index: -1; }
.media-host > * { width: 1px; height: 1px; }
9 changes: 7 additions & 2 deletions web/js/audio.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
// * monitor: what the host hears on their speakers — everyone except the
// host's own microphone.

import { keepInPage } from './util.js';

export class Mixer {
constructor() {
this.ctx = new AudioContext({ sampleRate: 48000, latencyHint: 'interactive' });
Expand Down Expand Up @@ -111,7 +113,7 @@ export class Mixer {
if (kind === 'guest') {
// Chrome only delivers remote WebRTC audio to Web Audio while the
// stream is also attached to a media element.
element = new Audio();
element = keepInPage(new Audio());
element.muted = true;
element.srcObject = input;
element.play().catch(() => {});
Expand All @@ -132,7 +134,10 @@ export class Mixer {
if (!s) return;
s.input.disconnect();
s.gain.disconnect();
if (s.element && s.kind === 'guest') s.element.srcObject = null;
if (s.element && s.kind === 'guest') {
s.element.srcObject = null;
s.element.remove();
}
this.sources.delete(id);
this.route();
}
Expand Down
5 changes: 4 additions & 1 deletion web/js/studio.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Mixer } from './audio.js';
import { t, setLang, getLang, detectLang } from './i18n.js';
import { PLATFORMS, platformGuide, helpTopics, cloudflaredInstall } from './help.js';
import {
h, icon, makeApi, hostToken, debounce, formatDuration, formatBytes, toast, copyText,
h, icon, makeApi, hostToken, debounce, formatDuration, formatBytes, toast, copyText, keepInPage,
} from './util.js';

const token = hostToken();
Expand Down Expand Up @@ -481,6 +481,7 @@ function stopSource(id) {
mediaElement.pause();
mediaElement.removeAttribute('src');
mediaElement.load();
mediaElement.remove();
mediaElement = null;
}
renderAll();
Expand All @@ -490,6 +491,8 @@ function stopSource(id) {
async function playMedia(asset) {
stopSource('media');
const video = h('video', { src: api.assetUrl(asset.id), playsInline: true, preload: 'auto' });
// In the page before play(): see keepInPage().
keepInPage(video);
mediaElement = video;
await mixer.resume();
try {
Expand Down
24 changes: 24 additions & 0 deletions web/js/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,30 @@ export async function copyText(text, input = null) {
}
}

let mediaHost = null;

/**
* Puts a media element that is only used for its sound or frames into the
* page, inside an invisible container, so it is never "detached".
*
* Detached <video>/<audio> elements are fragile: some browser extensions
* (KDE Plasma Integration, installed by default on many Linux desktops) add
* such an element to the page when it starts playing and remove it again,
* and Chromium pauses a media element removed from the page, rejecting
* play() with "The play() request was interrupted because the media was
* removed from the document." Elements already in the page are left alone.
*/
export function keepInPage(el) {
if (!mediaHost) {
mediaHost = document.createElement('div');
mediaHost.className = 'media-host';
mediaHost.setAttribute('aria-hidden', 'true');
document.body.append(mediaHost);
}
if (el.parentNode !== mediaHost) mediaHost.append(el);
return el;
}

export function toast(message, kind = 'info') {
let host = document.querySelector('.toasts');
if (!host) {
Expand Down
Loading