mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 17:15:24 +00:00
The native GStreamer screen share path is gone: in a peer-to-peer mesh every hardware and OS combination behaved differently, and keeping them in step cost more than it saved. Screen sharing uses Chromium's own capture everywhere. - Screen share bitrate is a setting: a stepper from 300 to 10,000 Kbps or unlimited, with press-and-hold repeat and typed entry. Changes apply live, including to a share going through the voice relay (debounced, so a hold becomes one update). The default stays 1080p at 30 fps with an 8,000 Kbps cap. - Black screen share tiles on GPU encoders: the sharer asks for a fresh keyframe after negotiation, and the watchdog only re-adopts tracks a tile is not already showing. - Relay screen publishing is serialized and tied to the share it was started for, so a stop, a new share or a relay restart can never publish a stale producer, and a failed first publish retries on its own.
188 lines
6.1 KiB
JavaScript
188 lines
6.1 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
const vm = require('node:vm');
|
|
|
|
// The voice UI is spread over four modules that the app merges into one
|
|
// object (the screen PiP code lives in app-stream-tiles.js); load them the
|
|
// same way.
|
|
const FILES = ['app-voice.js', 'app-screen-webcam.js', 'app-stream-tiles.js', 'app-music.js'];
|
|
const SOURCES = FILES.map(name => fs.readFileSync(path.join(__dirname, '..', 'public/js/modules', name), 'utf8'));
|
|
|
|
function loadMethods(globals = {}) {
|
|
const methods = {};
|
|
SOURCES.forEach((source, i) => {
|
|
const context = vm.createContext({ module: { exports: {} }, t: key => key, ...globals });
|
|
vm.runInContext(source.replace(/^export default/m, 'module.exports ='), context, {
|
|
filename: FILES[i],
|
|
});
|
|
Object.assign(methods, context.module.exports);
|
|
});
|
|
return methods;
|
|
}
|
|
|
|
function createClassList() {
|
|
const values = new Set();
|
|
return {
|
|
add: value => values.add(value),
|
|
remove: value => values.delete(value),
|
|
contains: value => values.has(value),
|
|
};
|
|
}
|
|
|
|
test('a reset invalidates a pending screen PiP continuation', async () => {
|
|
let resolvePictureInPicture;
|
|
let exitCalls = 0;
|
|
const button = { textContent: '', title: '' };
|
|
const video = {
|
|
srcObject: { id: 'old-stream' },
|
|
disablePictureInPicture: false,
|
|
requestPictureInPicture: () => new Promise(resolve => {
|
|
resolvePictureInPicture = resolve;
|
|
}),
|
|
addEventListener() {},
|
|
};
|
|
const tile = {
|
|
classList: createClassList(),
|
|
contains: element => element === video,
|
|
querySelector: selector => selector === 'video' ? video : button,
|
|
};
|
|
const document = {
|
|
pictureInPictureEnabled: true,
|
|
pictureInPictureElement: null,
|
|
getElementById: id => id === 'screen-tile-7' ? tile : null,
|
|
exitPictureInPicture: async () => {
|
|
exitCalls++;
|
|
document.pictureInPictureElement = null;
|
|
},
|
|
querySelector: () => null,
|
|
};
|
|
const methods = loadMethods({ document });
|
|
const app = Object.assign({ _updateStreamContainerCollapse() {} }, methods);
|
|
|
|
app._popOutStream(tile, 7);
|
|
app._removeScreenSharePiP(7);
|
|
document.pictureInPictureElement = video;
|
|
resolvePictureInPicture();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
assert.equal(exitCalls, 1);
|
|
assert.equal(tile.classList.contains('stream-popped-out'), false);
|
|
assert.equal(button.title, 'media.pop_out_stream');
|
|
});
|
|
|
|
test('screen PiP cleanup restores the previous track onended handler', () => {
|
|
let previousCalls = 0;
|
|
let pipCalls = 0;
|
|
const previousOnEnded = () => { previousCalls++; };
|
|
const track = { onended: previousOnEnded };
|
|
const document = {
|
|
pictureInPictureElement: null,
|
|
getElementById: () => null,
|
|
querySelector: () => null,
|
|
};
|
|
const methods = loadMethods({ document });
|
|
const app = Object.assign({ _updateStreamContainerCollapse() {} }, methods);
|
|
|
|
app._bindScreenPipTrack(7, track, () => { pipCalls++; });
|
|
const wrappedOnEnded = track.onended;
|
|
wrappedOnEnded();
|
|
assert.equal(previousCalls, 1);
|
|
assert.equal(pipCalls, 1);
|
|
|
|
app._removeScreenSharePiP(7);
|
|
assert.equal(track.onended, previousOnEnded);
|
|
assert.equal(app._screenPipTrackCleanups.has(7), false);
|
|
});
|
|
|
|
test('an old PiP completion does not close a newer request on the same video', async () => {
|
|
const resolvers = [];
|
|
let exitCalls = 0;
|
|
const button = { textContent: '', title: '' };
|
|
const video = {
|
|
srcObject: { id: 'current-stream' },
|
|
disablePictureInPicture: false,
|
|
requestPictureInPicture: () => new Promise(resolve => { resolvers.push(resolve); }),
|
|
addEventListener() {},
|
|
};
|
|
const tile = {
|
|
classList: createClassList(),
|
|
contains: element => element === video,
|
|
querySelector: selector => selector === 'video' ? video : button,
|
|
};
|
|
const document = {
|
|
pictureInPictureEnabled: true,
|
|
pictureInPictureElement: null,
|
|
getElementById: id => id === 'screen-tile-7' ? tile : null,
|
|
exitPictureInPicture: async () => { exitCalls++; },
|
|
querySelector: () => null,
|
|
};
|
|
const methods = loadMethods({ document });
|
|
const app = Object.assign({ _updateStreamContainerCollapse() {} }, methods);
|
|
|
|
app._popOutStream(tile, 7);
|
|
app._removeScreenSharePiP(7);
|
|
app._popOutStream(tile, 7);
|
|
document.pictureInPictureElement = video;
|
|
resolvers[1]();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
resolvers[0]();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
assert.equal(exitCalls, 0);
|
|
assert.equal(tile.classList.contains('stream-popped-out'), true);
|
|
assert.equal(button.title, 'media.pop_in_stream');
|
|
});
|
|
|
|
test('a rejected replacement adopts an older PiP that finished while it was pending', async () => {
|
|
const requests = [];
|
|
let exitCalls = 0;
|
|
let fallbackCalls = 0;
|
|
const button = { textContent: '', title: '' };
|
|
const video = {
|
|
srcObject: { id: 'current-stream' },
|
|
disablePictureInPicture: false,
|
|
requestPictureInPicture: () => new Promise((resolve, reject) => {
|
|
requests.push({ resolve, reject });
|
|
}),
|
|
addEventListener() {},
|
|
};
|
|
const tile = {
|
|
classList: createClassList(),
|
|
contains: element => element === video,
|
|
querySelector: selector => selector === 'video' ? video : button,
|
|
};
|
|
const document = {
|
|
pictureInPictureEnabled: true,
|
|
pictureInPictureElement: null,
|
|
getElementById: id => id === 'screen-tile-7' ? tile : null,
|
|
exitPictureInPicture: async () => { exitCalls++; },
|
|
querySelector: () => null,
|
|
};
|
|
const methods = loadMethods({ document });
|
|
const app = Object.assign({ _updateStreamContainerCollapse() {} }, methods);
|
|
app._popOutStreamWindow = () => { fallbackCalls++; };
|
|
|
|
app._popOutStream(tile, 7);
|
|
app._removeScreenSharePiP(7);
|
|
app._popOutStream(tile, 7);
|
|
document.pictureInPictureElement = video;
|
|
requests[0].resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
requests[1].reject(new Error('replacement rejected'));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
assert.equal(exitCalls, 0);
|
|
assert.equal(fallbackCalls, 0);
|
|
assert.equal(tile.classList.contains('stream-popped-out'), true);
|
|
assert.equal(button.title, 'media.pop_in_stream');
|
|
});
|