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:
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user