Screen sharing #7

Merged
kibi merged 6 commits from screen-sharing into main 2026-10-01 20:27:09 +00:00
5 changed files with 45 additions and 10 deletions
Showing only changes of commit 9880ef840f - Show all commits
+8
View File
@@ -27,6 +27,10 @@ export const SOUND_EVENTS = [
{ id: 'userBanned', group: 'Your channel', name: 'Someone in your channel was banned', ts: 'CLIENT_WAS_BANNED_CURRENT_CHANNEL', keys: ['banned_server_currentchannel'] },
{ id: 'recordingStarted', group: 'Your channel', name: 'Someone started recording', ts: 'CLIENT_RECORDING_START', keys: ['recording_started'] },
{ id: 'recordingStopped', group: 'Your channel', name: 'Someone stopped recording', ts: 'CLIENT_RECORDING_STOP', keys: ['recording_stopped'] },
{ id: 'shareStarted', group: 'Screen sharing', name: 'A screen share started (yours or in your channel)', ts: 'MUMH5_SHARE_STARTED', keys: ['share_started', 'stream_started'] },
{ id: 'shareStopped', group: 'Screen sharing', name: 'A screen share stopped', ts: 'MUMH5_SHARE_STOPPED', keys: ['share_stopped', 'stream_stopped'] },
{ id: 'viewerJoined', group: 'Screen sharing', name: 'Someone started watching your screen', ts: 'MUMH5_VIEWER_JOINED', keys: ['viewer_joined'] },
{ id: 'viewerLeft', group: 'Screen sharing', name: 'Someone stopped watching your screen', ts: 'MUMH5_VIEWER_LEFT', keys: ['viewer_left'] },
{ id: 'message', group: 'Chat', name: 'Channel message', ts: 'CHAT_RECEIVED_MESSAGE_CHANNEL', keys: ['chat_message_inbound', 'message'] },
{ id: 'privateMessage', group: 'Chat', name: 'Direct message', ts: 'CHAT_RECEIVED_MESSAGE_CLIENT', keys: ['private', 'direct', 'pm'] },
{ id: 'messageSent', group: 'Chat', name: 'You sent a message', ts: 'CHAT_SENT_MESSAGE_CHANNEL', keys: ['chat_message_outbound', 'sent'], off: true },
@@ -79,6 +83,10 @@ const TONES: Record<SoundEvent, [number, number, number][]> = {
userBanned: [[294, 0, 0.12], [220, 0.13, 0.16]],
recordingStarted: [[1175, 0, 0.08], [1175, 0.12, 0.08]],
recordingStopped: [[880, 0, 0.12]],
shareStarted: [[523, 0, 0.07], [784, 0.08, 0.07], [1047, 0.16, 0.12]],
shareStopped: [[1047, 0, 0.07], [784, 0.08, 0.07], [523, 0.16, 0.12]],
viewerJoined: [[880, 0, 0.05], [1109, 0.06, 0.08]],
viewerLeft: [[1109, 0, 0.05], [880, 0.06, 0.08]],
message: [[1047, 0, 0.06]],
privateMessage: [[1047, 0, 0.05], [1319, 0.07, 0.07]],
messageSent: [[1319, 0, 0.04]],
+22 -2
View File
@@ -9,6 +9,7 @@ import { desktop, isWeb } from './native.ts';
import { proxyStun } from './web.svelte.ts';
import { store } from './settings.svelte.ts';
import { ui } from './ui.svelte.ts';
import { sounds } from './audio/sounds.svelte.ts';
// Every viewer is a separate upload from the sharer
const MAX_VIEWERS = 8;
@@ -44,6 +45,10 @@ class ScreenShare {
private watchPc: RTCPeerConnection | null = null;
private watchTimer: ReturnType<typeof setTimeout> | undefined;
private assemblers = new WeakMap<Session, ShareAssembler>();
// Viewers whose connection came up, so leaving is only announced for those
private live = new Set<number>();
// When we entered the channel: streams already running there are not announced with a sound
private enteredAt = new WeakMap<Session, number>();
// Plugin data needs a 1.4 server; older ones would not pass the signals on
supported(s: Session): boolean {
@@ -114,6 +119,7 @@ class ScreenShare {
this.stream = stream;
this.send(s, this.others(s), SHARE.on);
s.setSharing(s.client.session!, true);
sounds.play('shareStarted');
}
private release(): void {
@@ -127,6 +133,7 @@ class ScreenShare {
stop(): void {
const s = this.host;
if (this.stream) sounds.play('shareStopped');
if (s?.client) {
this.send(s, this.others(s), SHARE.off);
if (s.client.session != null) s.setSharing(s.client.session, false);
@@ -137,12 +144,14 @@ class ScreenShare {
private dropPeers(): void {
for (const pc of this.peers.values()) pc.close();
this.peers.clear();
this.live.clear();
this.viewers = 0;
}
private dropPeer(session: number): void {
this.peers.get(session)?.close();
this.peers.delete(session);
if (this.live.delete(session)) sounds.play('viewerLeft');
this.viewers = this.peers.size;
}
@@ -195,7 +204,11 @@ class ScreenShare {
this.peers.set(viewer, pc);
this.viewers = this.peers.size;
pc.addEventListener('connectionstatechange', () => {
if ((pc.connectionState === 'failed' || pc.connectionState === 'closed') && this.peers.get(viewer) === pc) this.dropPeer(viewer);
if (this.peers.get(viewer) !== pc) return;
if (pc.connectionState === 'connected' && !this.live.has(viewer)) {
this.live.add(viewer);
sounds.play('viewerJoined');
} else if (pc.connectionState === 'failed' || pc.connectionState === 'closed') this.dropPeer(viewer);
});
for (const track of stream.getTracks()) {
const sender = pc.addTrack(track, stream);
@@ -241,8 +254,12 @@ class ScreenShare {
// Only people in our channel take part
if (!msg || !s.client || !this.inMyChannel(s, sender)) return;
switch (msg.type) {
case SHARE.on: s.setSharing(sender, true); break;
case SHARE.on:
if (!s.sharing[sender] && Date.now() - (this.enteredAt.get(s) ?? 0) > 3000) sounds.play('shareStarted');
s.setSharing(sender, true);
break;
case SHARE.off:
if (s.sharing[sender]) sounds.play('shareStopped');
s.setSharing(sender, false);
if (this.watching?.host === s && this.watching.session === sender) this.endWatch();
break;
@@ -260,6 +277,7 @@ class ScreenShare {
entered(s: Session): void {
if (!this.supported(s)) return;
const self = s.client!.session!;
this.enteredAt.set(s, Date.now());
s.clearSharing();
if (this.watching?.host === s) this.unwatch();
if (this.host === s) {
@@ -277,6 +295,8 @@ class ScreenShare {
if (this.host === s) this.send(s, [user.session], SHARE.on);
return;
}
// A sharer who left or disconnected: their stream is over for us
if (s.sharing[user.session]) sounds.play('shareStopped');
s.setSharing(user.session, false);
if (this.host === s) this.dropPeer(user.session);
if (this.watching?.host === s && this.watching.session === user.session) this.endWatch();
+2 -2
View File
@@ -190,7 +190,7 @@
{/if}
</button>
{#if mine}
<span class="status" role="status">You are sharing your screen{mine.getAudioTracks().length ? ' with sound' : ''}{share.viewers ? `, ${share.viewers} watching` : ''}</span>
<span class="status" role="status" title="People watching your screen"><Icon name="screen" size={14} />{share.viewers} watching</span>
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
{/if}
<button class="icon-btn" class:on={self.selfMute} aria-label={self.selfMute ? 'Unmute' : 'Mute'} title={self.selfMute ? 'Unmute' : 'Mute'}
@@ -306,7 +306,7 @@
.bar span { flex: 1; min-width: 0; }
.bar.bad { color: var(--danger); }
.btn.small, .bar .btn { padding: 4px 10px; font-size: 12px; flex: none; }
.status { font-size: 12px; color: var(--speaking); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; margin-right: 4px; }
.status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--speaking); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; margin-right: 4px; }
/* Meeting view: the chosen picture large, everyone in one row below */
.spot { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #000; }
+11 -4
View File
@@ -72,7 +72,7 @@ try {
};
await picker.getByLabel('Quality').selectOption('low');
await pickAndStart();
await alice.page.getByText('You are sharing your screen').waitFor();
await alice.page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
await alice.page.locator('.sidebar .row.user.self').getByTitle('You are sharing your screen').waitFor();
// 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 });
@@ -111,7 +111,7 @@ try {
await view.getByText(aliceName).waitFor();
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();
await alice.page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
console.log(`ok: bob receives the picture (${size[0]}x${size[1]}), alice sees one viewer`);
if (process.env.SHOTS_DIR) { await bob.page.waitForTimeout(500); await bob.page.screenshot({ path: `${process.env.SHOTS_DIR}/meeting.png` }); }
@@ -137,7 +137,7 @@ try {
// 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 alice.page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
// 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');
@@ -152,7 +152,7 @@ try {
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);
assert.equal(await alice.page.locator('.stage .status').count(), 0);
console.log('ok: stopping ends the stream for viewers');
// A sharer who disconnects takes the stream with them
@@ -163,6 +163,13 @@ try {
await watch.waitFor({ state: 'detached' });
console.log('ok: disconnecting ends the stream');
// Sounds: the sharer hears the stream start and stop and viewers come and go; the channel hears streams start and stop
const sounds = (p: Page) => p.evaluate(() => [...((window as any).__mumh5Sounds ?? [])] as string[]);
const heardA = await sounds(alice.page), heardB = await sounds(bob.page);
for (const ev of ['shareStarted', 'viewerJoined', 'viewerLeft', 'shareStopped']) assert.ok(heardA.includes(ev), `sharer hears ${ev}: ${heardA}`);
for (const ev of ['shareStarted', 'shareStopped']) assert.ok(heardB.includes(ev), `viewer hears ${ev}: ${heardB}`);
console.log('ok: sounds for streams and viewers');
console.log('SHARE E2E PASSED');
} finally {
await alice.app.close().catch(() => {});
+2 -2
View File
@@ -183,11 +183,11 @@ try {
await dialog.getByRole('button', { name: 'Choose a screen or window...' }).click();
await page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 60000 });
await dialog.getByRole('button', { name: 'Start sharing' }).click();
await page.getByText('You are sharing your screen').waitFor();
await page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
await page2.locator('.stage .tile', { hasText: name }).click();
await page2.getByRole('button', { name: 'Continue' }).click();
await page2.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 });
await page.getByText('You are sharing your screen, 1 watching').waitFor();
await page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
console.log('ok: screen sharing between two browsers');
console.log('WEB E2E PASSED');