Screen sharing: start dialog, sound, video tiles and a meeting view

- Start dialog with source list, live preview, sound and quality choices
- Sound on Linux through PipeWire: one program or everything except this
  app, fed into a virtual microphone and kept linked as programs come and go;
  system loopback on Windows; stereo Opus
- A sharer's tile shows the picture; clicking it opens a large view with the
  other tiles as a strip. Own stream is previewed in the own tile
- Stream volume for viewers, watch and stop from the profile panel
- Unit tests for the PipeWire link planner; the proxy test no longer writes to
  the root channel other tests listen in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 21:19:29 +02:00
co-authored by Claude Opus 5.5
parent 0cce3c97c2
commit 3b23f69a5f
20 changed files with 744 additions and 185 deletions
+2
View File
@@ -886,6 +886,8 @@ try {
// Custom icon from the tile's context menu: square-cropped and shown instead of initials
const iconFile = path.join(userData, 'icon.png');
writeFileSync(iconFile, testPng(300, 200));
// With hints off, the title of whatever the pointer rests on is set aside; move off the tile first
await page.mouse.move(700, 400);
await page.locator('.rail .tile[title^="Second"]').click({ button: 'right' });
const chooser = page.waitForEvent('filechooser');
await page.getByRole('menuitem', { name: 'Change icon...' }).click();
+48 -10
View File
@@ -56,45 +56,83 @@ try {
await shareBtn.click();
await alice.page.getByText(/You see each other's IP address/).waitFor();
await alice.page.getByRole('button', { name: 'Continue' }).click();
// The dialog: pick a source, see the preview, then start
const picker = alice.page.getByRole('dialog', { name: 'Share your screen' });
await picker.locator('.sources button').first().click();
const startBtn = picker.getByRole('button', { name: 'Start sharing' });
const pickAndStart = async () => {
assert.ok(await startBtn.isDisabled(), 'nothing can start before a source is chosen');
// Listing is slow on the bare test display and can come back empty the first time
for (let i = 0; i < 6 && !(await picker.locator('.sources button').count()); i++) {
await Promise.race([picker.locator('.sources button').first().waitFor({ timeout: 40000 }), picker.getByText('Nothing found').waitFor({ timeout: 40000 })]).catch(() => {});
if (await picker.getByText('Nothing found').count()) await picker.getByRole('button', { name: 'Refresh' }).click();
}
await picker.locator('.sources button').first().click();
await alice.page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
await startBtn.click();
};
await picker.getByLabel('Quality').selectOption('low');
await pickAndStart();
await alice.page.getByText('You are sharing your screen').waitFor();
await alice.page.locator('.sidebar .row.user.self').getByTitle('You are sharing your screen').waitFor();
console.log('ok: sharing started from the picker');
// The sharer's own tile shows what is being sent; clicking it makes it large
await alice.page.waitForFunction(() => (document.querySelector('.stage .tile.self video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
await alice.page.locator('.stage .tile.self').click();
const ownView = alice.page.getByRole('region', { name: 'Screen share' });
await ownView.getByText('Your screen').waitFor();
await ownView.getByRole('button', { name: 'Back to tiles' }).click();
await ownView.waitFor({ state: 'detached' });
console.log('ok: sharing started from the dialog, own tile shows the stream');
// Bob connects afterwards and still learns about the stream
await join(bob.page, bobName);
const watch = bob.page.getByRole("button", { name: `Watch the screen of ${aliceName}`, exact: true });
// Next to the name in the channel list, and on the tile above the chat
const watch = bob.page.locator('.sidebar').getByRole('button', { name: `Watch the screen of ${aliceName}`, exact: true });
await watch.waitFor();
const tile = bob.page.locator('.stage .tile', { hasText: aliceName });
await tile.waitFor();
console.log('ok: a late joiner sees who is sharing');
await watch.click();
await tile.click();
await bob.page.getByRole('button', { name: 'Continue' }).click();
const view = bob.page.getByRole('region', { name: 'Screen share' });
await view.getByText(aliceName).waitFor();
await bob.page.waitForFunction(() => (document.querySelector('.watch video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
const size = await bob.page.evaluate(() => { const v = document.querySelector('.watch video') as HTMLVideoElement; return [v.videoWidth, v.videoHeight]; });
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
const size = await bob.page.evaluate(() => { const v = document.querySelector('.spot video') as HTMLVideoElement; return [v.videoWidth, v.videoHeight]; });
await alice.page.getByText('You are sharing your screen, 1 watching').waitFor();
console.log(`ok: bob receives the picture (${size[0]}x${size[1]}), alice sees one viewer`);
// Like a meeting: back to tiles keeps the picture in the tile, a click makes it large again
await view.getByRole('button', { name: 'Back to tiles' }).click();
await view.waitFor({ state: 'detached' });
await bob.page.waitForFunction(() => (document.querySelector('.stage .tile.video video') as HTMLVideoElement | null)?.videoWidth! > 0);
await tile.click();
await view.waitFor();
console.log('ok: stream in the tile, large on click');
// Leaving and coming back
await view.getByRole('button', { name: 'Stop watching' }).click();
await view.waitFor({ state: 'detached' });
await alice.page.getByText('You are sharing your screen', { exact: true }).waitFor();
await watch.click();
await bob.page.waitForFunction(() => (document.querySelector('.watch video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
console.log('ok: stop watching and watch again');
// The sharer's profile says so and offers to watch
await bob.page.locator('.sidebar .row.user', { hasText: aliceName }).click();
const profile = bob.page.locator('.panel .sharing');
await profile.getByText('Sharing their screen').waitFor();
await profile.getByRole('button', { name: 'Watch' }).click();
await profile.getByRole('button', { name: 'Stop watching' }).waitFor();
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
console.log('ok: stop watching, watch again from the profile');
// Alice stops: the view and the indicator go away
await alice.page.locator('.me').getByRole('button', { name: 'Stop sharing your screen' }).click();
await view.waitFor({ state: 'detached' });
await watch.waitFor({ state: 'detached' });
await profile.waitFor({ state: 'detached' });
assert.equal(await alice.page.getByText('You are sharing your screen').count(), 0);
console.log('ok: stopping ends the stream for viewers');
// A sharer who disconnects takes the stream with them
await shareBtn.click();
await picker.locator('.sources button').first().click();
await pickAndStart();
await watch.waitFor();
await alice.page.getByTitle('Disconnect').click();
await watch.waitFor({ state: 'detached' });
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { audioApps, planLinks, VIRTUAL_MIC } from '../electron/pipewire.ts';
// A cut-down pw-dump: the virtual microphone (10), this app (20, process 500), a browser whose
// stream has no name of its own (30, named by its client), a mono game (40) and a music player (50)
const node = (id: number, props: Record<string, unknown>) => ({ id, type: 'PipeWire:Interface:Node', info: { props } });
const client = (id: number, name: string, pid: number) => ({ id, type: 'PipeWire:Interface:Client', info: { props: { 'application.name': name, 'application.process.id': pid } } });
const port = (id: number, nodeId: number, direction: string, channel: string) => ({ id, type: 'PipeWire:Interface:Port', info: { direction, props: { 'node.id': nodeId, 'audio.channel': channel } } });
const link = (id: number, outPort: number, inPort: number, inNode: number) =>
({ id, type: 'PipeWire:Interface:Link', info: { 'output-port-id': outPort, 'input-port-id': inPort, 'input-node-id': inNode } as any });
const graph = [
node(10, { 'node.name': VIRTUAL_MIC, 'media.class': 'Audio/Source/Virtual' }), port(11, 10, 'input', 'FL'), port(12, 10, 'input', 'FR'),
port(13, 10, 'output', 'FL'), port(14, 10, 'output', 'FR'),
client(2, 'mumh5', 500), node(20, { 'media.class': 'Stream/Output/Audio', 'node.name': 'mumh5', 'client.id': 2 }), port(21, 20, 'output', 'FL'), port(22, 20, 'output', 'FR'),
client(3, 'Firefox', 600), node(30, { 'media.class': 'Stream/Output/Audio', 'node.name': 'Firefox', 'client.id': 3 }), port(31, 30, 'output', 'FL'), port(32, 30, 'output', 'FR'),
node(40, { 'media.class': 'Stream/Output/Audio', 'application.name': 'Game', 'application.process.id': 700 }), port(41, 40, 'output', 'MONO'),
node(50, { 'media.class': 'Stream/Output/Audio', 'application.name': 'Spotify' }), port(51, 50, 'output', 'FL'), port(52, 50, 'output', 'FR'), port(53, 50, 'output', 'LFE'),
node(60, { 'media.class': 'Audio/Sink', 'node.name': 'speakers' })
];
test('lists programs that play sound, without this app', () => {
assert.deepEqual(audioApps(graph, [500]), ['Firefox', 'Game', 'Spotify']);
});
test('whole system links every program but this app', () => {
const { add, remove } = planLinks(graph, { kind: 'all' }, [500]);
assert.deepEqual(add.sort(), [[31, 11], [32, 12], [41, 11], [41, 12], [51, 11], [52, 12]].sort());
assert.deepEqual(remove, []);
});
test('one program links only that program', () => {
assert.deepEqual(planLinks(graph, { kind: 'app', name: 'Firefox' }, [500]).add, [[31, 11], [32, 12]]);
});
test('existing links are kept, stale ones removed', () => {
const linked = [...graph, link(90, 31, 11, 10), link(91, 51, 11, 10), link(92, 21, 60, 60)];
const { add, remove } = planLinks(linked, { kind: 'app', name: 'Firefox' }, [500]);
assert.deepEqual(add, [[32, 12]]);
// Spotify's link goes; the link to the speakers is none of our business
assert.deepEqual(remove, [91]);
});
test('nothing without the virtual microphone', () => {
assert.deepEqual(planLinks(graph.slice(5), { kind: 'all' }, []), { add: [], remove: [] });
});
+2 -1
View File
@@ -181,7 +181,8 @@ test('two clients talk through the proxy', { skip: !target }, async () => {
const a = await connect(`proxy-a-${Date.now() % 100000}`);
const b = await connect(`proxy-b-${Date.now() % 100000}`);
const got = new Promise<string>(resolve => b.on('text', m => resolve(m.html)));
a.sendText({ channels: [0] }, 'hello through the proxy');
// A direct message, so other test files listening in the root channel are not disturbed
a.sendText({ users: [b.session!] }, 'hello through the proxy');
assert.equal(await got, 'hello through the proxy');
// No UDP in a browser: voice stays on the TCP tunnel
assert.equal(a.udpOk, false);