mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-26 08:38:20 +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.
147 lines
6.5 KiB
JavaScript
147 lines
6.5 KiB
JavaScript
/**
|
|
* Automatic relay profile for screen share (#5426).
|
|
*
|
|
* The "Gentler screen share for relay connections" toggle was confirmed on
|
|
* two setups as the thing a relayed share needs. This applies it by itself,
|
|
* per viewer: once a peer connection settles, its selected candidate pair
|
|
* says whether the path runs through a TURN relay, and only that viewer's
|
|
* sender gets the gentler encoder settings.
|
|
*
|
|
* node --test test/screenRelayAuto.test.js
|
|
*/
|
|
'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');
|
|
|
|
const VOICE_SOURCE = fs.readFileSync(path.join(__dirname, '..', 'public/js/voice.js'), 'utf8');
|
|
|
|
function loadVoiceManager(storage = {}) {
|
|
const context = vm.createContext({
|
|
module: { exports: {} },
|
|
navigator: { userAgent: '', platform: '', maxTouchPoints: 0 },
|
|
localStorage: {
|
|
getItem(k) { return Object.prototype.hasOwnProperty.call(storage, k) ? storage[k] : null; },
|
|
setItem(k, v) { storage[k] = String(v); },
|
|
removeItem(k) { delete storage[k]; }
|
|
},
|
|
console: { log() {}, warn() {}, error() {} },
|
|
RTCSessionDescription: function RTCSessionDescription(d) { return d; },
|
|
setTimeout,
|
|
clearTimeout,
|
|
Date
|
|
});
|
|
vm.runInContext(`${VOICE_SOURCE}\nmodule.exports = VoiceManager;`, context, { filename: 'voice.js' });
|
|
return context.module.exports;
|
|
}
|
|
|
|
// A peer connection whose stats say the selected pair is relayed or direct,
|
|
// with one screen-share video sender that records what it was told.
|
|
function fakeConnection(localType, remoteType, videoTrack) {
|
|
const stats = new Map([
|
|
['T1', { id: 'T1', type: 'transport', selectedCandidatePairId: 'P1' }],
|
|
['P1', { id: 'P1', type: 'candidate-pair', state: 'succeeded', nominated: true, localCandidateId: 'L1', remoteCandidateId: 'R1' }],
|
|
['L1', { id: 'L1', type: 'local-candidate', candidateType: localType }],
|
|
['R1', { id: 'R1', type: 'remote-candidate', candidateType: remoteType }]
|
|
]);
|
|
const sender = {
|
|
track: videoTrack,
|
|
applied: null,
|
|
getParameters() { return { encodings: [{}] }; },
|
|
setParameters(p) { this.applied = p; return Promise.resolve(); }
|
|
};
|
|
return {
|
|
sender,
|
|
getStats: async () => stats,
|
|
getSenders: () => [sender]
|
|
};
|
|
}
|
|
|
|
function makeSharer(VoiceManager, { local = 'host', remote = 'srflx' } = {}) {
|
|
const voice = Object.create(VoiceManager.prototype);
|
|
const track = { kind: 'video', readyState: 'live' };
|
|
voice.peers = new Map();
|
|
voice.screenStream = { getVideoTracks: () => [track], getTracks: () => [track] };
|
|
voice.isScreenSharing = true;
|
|
voice.screenResolution = 1080;
|
|
voice.screenFrameRate = 30;
|
|
const connection = fakeConnection(local, remote, track);
|
|
voice.peers.set(2, { connection, username: 'viewer' });
|
|
return { voice, connection };
|
|
}
|
|
|
|
test('a relayed viewer gets the gentler profile on their sender only', async () => {
|
|
const VoiceManager = loadVoiceManager();
|
|
const { voice, connection } = makeSharer(VoiceManager, { local: 'relay' });
|
|
await voice._detectRelayPath(2, connection);
|
|
assert.equal(voice._relayPeers.has(2), true);
|
|
const p = connection.sender.applied;
|
|
assert.ok(p, 'sender parameters were re-applied once the relay was detected');
|
|
assert.equal(p.encodings[0].maxBitrate, 3_000_000, 'gentler 1080p ceiling');
|
|
assert.equal(p.encodings[0].maxFramerate, undefined, 'framerate left unpinned');
|
|
assert.equal(p.degradationPreference, 'balanced');
|
|
});
|
|
|
|
test('the remote end being a relay counts too', async () => {
|
|
const VoiceManager = loadVoiceManager();
|
|
const { voice, connection } = makeSharer(VoiceManager, { local: 'srflx', remote: 'relay' });
|
|
await voice._detectRelayPath(2, connection);
|
|
assert.equal(voice._relayPeers.has(2), true);
|
|
});
|
|
|
|
test('a direct viewer keeps the full-quality profile', async () => {
|
|
const VoiceManager = loadVoiceManager();
|
|
const { voice, connection } = makeSharer(VoiceManager);
|
|
await voice._detectRelayPath(2, connection);
|
|
assert.equal(voice._relayPeers.has(2), false);
|
|
// Nothing changed, so nothing was re-applied by detection; apply explicitly
|
|
// the way shareScreen does and check the full profile is what goes out.
|
|
voice._applyScreenBitrate(connection, 8_000_000, 2);
|
|
const p = connection.sender.applied;
|
|
assert.equal(p.encodings[0].maxBitrate, 8_000_000);
|
|
assert.equal(p.encodings[0].maxFramerate, 30);
|
|
assert.equal(p.degradationPreference, 'maintain-framerate');
|
|
});
|
|
|
|
test('switching the detection off in Debug leaves relayed viewers on the full profile', async () => {
|
|
const VoiceManager = loadVoiceManager({ haven_screen_relay_auto: '0' });
|
|
const { voice, connection } = makeSharer(VoiceManager, { local: 'relay' });
|
|
await voice._detectRelayPath(2, connection);
|
|
assert.equal(voice._relayPeers.has(2), true, 'the path is still recorded');
|
|
voice._applyScreenBitrate(connection, 8_000_000, 2);
|
|
assert.equal(connection.sender.applied.encodings[0].maxBitrate, 8_000_000);
|
|
assert.equal(connection.sender.applied.degradationPreference, 'maintain-framerate');
|
|
});
|
|
|
|
test('the manual toggle still applies the gentler profile to everyone', () => {
|
|
const VoiceManager = loadVoiceManager({ haven_screen_relay_profile: '1' });
|
|
const { voice, connection } = makeSharer(VoiceManager);
|
|
voice._applyScreenBitrate(connection, 8_000_000, 2);
|
|
assert.equal(connection.sender.applied.encodings[0].maxBitrate, 3_000_000);
|
|
assert.equal(connection.sender.applied.degradationPreference, 'balanced');
|
|
});
|
|
|
|
test('a peer torn down while stats were pending is not recorded', async () => {
|
|
const VoiceManager = loadVoiceManager();
|
|
const { voice, connection } = makeSharer(VoiceManager, { local: 'relay' });
|
|
const slow = { ...connection, getStats: () => new Promise((res) => setTimeout(() => res(connection.getStats()), 20)) };
|
|
voice.peers.set(2, { connection: slow, username: 'viewer' });
|
|
const pending = voice._detectRelayPath(2, slow);
|
|
voice.peers.delete(2);
|
|
await pending;
|
|
assert.equal(voice._relayPeers.has(2), false);
|
|
assert.equal(connection.sender.applied, null, 'nothing applied to a closed peer');
|
|
});
|
|
|
|
test('the userId is recovered from the connection when a caller does not pass it', async () => {
|
|
const VoiceManager = loadVoiceManager();
|
|
const { voice, connection } = makeSharer(VoiceManager, { local: 'relay' });
|
|
await voice._detectRelayPath(2, connection);
|
|
connection.sender.applied = null;
|
|
voice._applyScreenBitrate(connection, 8_000_000);
|
|
assert.equal(connection.sender.applied.encodings[0].maxBitrate, 3_000_000);
|
|
});
|