Merge pull request 'Screen sharing' (#17) from screen-sharing into main
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
@@ -6,6 +6,7 @@ export const ICON_PATHS: Record<string, string> = {
|
|||||||
'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18',
|
'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18',
|
||||||
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
|
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
|
||||||
plus: 'M12 5v14M5 12h14',
|
plus: 'M12 5v14M5 12h14',
|
||||||
|
switch: 'M4 8h13l-3-3M20 16H7l3 3',
|
||||||
camera: 'M2 6h14v12H2zM16 10l6-3v10l-6-3',
|
camera: 'M2 6h14v12H2zM16 10l6-3v10l-6-3',
|
||||||
screen: 'M3 4h18v12H3zM8 20h8M12 16v4',
|
screen: 'M3 4h18v12H3zM8 20h8M12 16v4',
|
||||||
popout: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6',
|
popout: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6',
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ class ScreenShare {
|
|||||||
host = $state.raw<Session | null>(null);
|
host = $state.raw<Session | null>(null);
|
||||||
stream = $state.raw<MediaStream | null>(null);
|
stream = $state.raw<MediaStream | null>(null);
|
||||||
kind = $state<ShareKind>('screen');
|
kind = $state<ShareKind>('screen');
|
||||||
|
// Show our own camera mirrored to ourselves, as a mirror would. Not a camera pointing away
|
||||||
|
// from us (a phone's back camera), and never what the others get.
|
||||||
|
mirror = $state(false);
|
||||||
|
// How many cameras this device has; with more than one there is something to switch to
|
||||||
|
cameras = $state(0);
|
||||||
|
switching = $state(false);
|
||||||
viewers = $state(0);
|
viewers = $state(0);
|
||||||
// Streams we receive: one screen at a time, and any number of cameras next to it
|
// Streams we receive: one screen at a time, and any number of cameras next to it
|
||||||
watches = $state.raw<Watching[]>([]);
|
watches = $state.raw<Watching[]>([]);
|
||||||
@@ -142,6 +148,9 @@ class ScreenShare {
|
|||||||
}
|
}
|
||||||
this.usesVirtualMic = usesVirtualMic;
|
this.usesVirtualMic = usesVirtualMic;
|
||||||
this.kind = kind;
|
this.kind = kind;
|
||||||
|
this.mirror = kind === 'camera' && stream.getVideoTracks()[0]?.getSettings().facingMode !== 'environment';
|
||||||
|
this.cameras = 0;
|
||||||
|
if (kind === 'camera') navigator.mediaDevices.enumerateDevices().then(d => { this.cameras = d.filter(x => x.kind === 'videoinput').length; }).catch(() => {});
|
||||||
this.host = s;
|
this.host = s;
|
||||||
this.stream = stream;
|
this.stream = stream;
|
||||||
this.send(s, this.others(s), SHARE.on, kind);
|
this.send(s, this.others(s), SHARE.on, kind);
|
||||||
@@ -149,6 +158,60 @@ class ScreenShare {
|
|||||||
sounds.play('shareStarted');
|
sounds.play('shareStarted');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The next camera of this device (front and back on a phone), without interrupting the stream
|
||||||
|
async switchCamera(): Promise<void> {
|
||||||
|
const stream = this.stream;
|
||||||
|
const old = stream?.getVideoTracks()[0];
|
||||||
|
if (!stream || !old || this.kind !== 'camera' || this.switching) return;
|
||||||
|
this.switching = true;
|
||||||
|
this.error = '';
|
||||||
|
const open = (deviceId: string) => navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { deviceId: { exact: deviceId }, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: QUALITY[store.settings.shareQuality].fps } }
|
||||||
|
}).catch(() => navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: deviceId } } }));
|
||||||
|
try {
|
||||||
|
const ids = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'videoinput').map(d => d.deviceId);
|
||||||
|
this.cameras = ids.length;
|
||||||
|
const current = old.getSettings().deviceId ?? '';
|
||||||
|
const next = ids[(ids.indexOf(current) + 1) % ids.length];
|
||||||
|
if (ids.length < 2 || !next || next === current) return;
|
||||||
|
let fresh: MediaStream;
|
||||||
|
try {
|
||||||
|
// Computers can have both open for a moment, which keeps the picture going
|
||||||
|
fresh = await open(next);
|
||||||
|
} catch {
|
||||||
|
// A phone opens one camera at a time, so the old one has to go first
|
||||||
|
old.stop();
|
||||||
|
try {
|
||||||
|
fresh = await open(next);
|
||||||
|
} catch (e) {
|
||||||
|
// Could not get the other one: back to the camera we had
|
||||||
|
fresh = await open(current).catch(() => { throw e; });
|
||||||
|
this.error = `Could not switch the camera: ${(e as Error).message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stopped sharing in the meantime
|
||||||
|
if (this.stream !== stream) return fresh.getTracks().forEach(t => t.stop());
|
||||||
|
const track = fresh.getVideoTracks()[0];
|
||||||
|
track.contentHint = 'motion';
|
||||||
|
track.addEventListener('ended', () => { if (this.stream?.getVideoTracks()[0] === track) this.stop(); });
|
||||||
|
// Viewers keep their connection; only the picture changes
|
||||||
|
for (const pc of this.peers.values()) {
|
||||||
|
for (const sender of pc.getSenders()) {
|
||||||
|
if (sender.track !== old) continue;
|
||||||
|
await sender.replaceTrack(track).catch(e => { this.error = `A viewer did not get the new camera: ${(e as Error).message}`; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
old.stop();
|
||||||
|
this.stream = new MediaStream([track, ...stream.getAudioTracks()]);
|
||||||
|
this.mirror = track.getSettings().facingMode !== 'environment';
|
||||||
|
} catch (e) {
|
||||||
|
this.error = `The camera stopped: ${(e as Error).message}`;
|
||||||
|
this.stop();
|
||||||
|
} finally {
|
||||||
|
this.switching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private release(): void {
|
private release(): void {
|
||||||
this.dropPeers();
|
this.dropPeers();
|
||||||
this.stream?.getTracks().forEach(t => t.stop());
|
this.stream?.getTracks().forEach(t => t.stop());
|
||||||
|
|||||||
@@ -220,7 +220,7 @@
|
|||||||
<span class="label">Preview</span>
|
<span class="label">Preview</span>
|
||||||
<div class="preview">
|
<div class="preview">
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
<video bind:this={video} autoplay playsinline muted></video>
|
<video bind:this={video} class:mirror={kind === 'camera' && preview?.getVideoTracks()[0]?.getSettings().facingMode !== 'environment'} autoplay playsinline muted></video>
|
||||||
{#if !preview}<p>Nothing chosen yet</p>{/if}
|
{#if !preview}<p>Nothing chosen yet</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -280,6 +280,8 @@
|
|||||||
.name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.preview { position: relative; background: #000; aspect-ratio: 16 / 9; max-height: 240px; margin: 0 auto; width: 100%; }
|
.preview { position: relative; background: #000; aspect-ratio: 16 / 9; max-height: 240px; margin: 0 auto; width: 100%; }
|
||||||
.preview video { width: 100%; height: 100%; object-fit: contain; display: block; }
|
.preview video { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||||
|
/* Your own camera as a mirror shows you; a camera facing away is left as it is */
|
||||||
|
.preview video.mirror { transform: scaleX(-1); }
|
||||||
.preview p { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; color: #aaa; font-size: 13px; }
|
.preview p { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; color: #aaa; font-size: 13px; }
|
||||||
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 12px; }
|
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 12px; }
|
||||||
.check input { width: auto; }
|
.check input { width: auto; }
|
||||||
|
|||||||
@@ -237,6 +237,14 @@
|
|||||||
{#if voice.error && session.status === 'connected'}<div class="bar bad" role="alert"><span>Voice is not working: {voice.error}</span></div>{/if}
|
{#if voice.error && session.status === 'connected'}<div class="bar bad" role="alert"><span>Voice is not working: {voice.error}</span></div>{/if}
|
||||||
{#if share.error}<div class="bar bad" role="alert"><span>{share.error}</span><button class="btn" onclick={() => (share.error = '')}>Dismiss</button></div>{/if}
|
{#if share.error}<div class="bar bad" role="alert"><span>{share.error}</span><button class="btn" onclick={() => (share.error = '')}>Dismiss</button></div>{/if}
|
||||||
|
|
||||||
|
{#snippet switcher()}
|
||||||
|
{#if mine && share.kind === 'camera' && share.cameras > 1}
|
||||||
|
<button class="icon-btn" title="Switch to the next camera" aria-label="Switch camera" disabled={share.switching} onclick={() => share.switchCamera()}>
|
||||||
|
<Icon name="switch" size={18} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet spot()}
|
{#snippet spot()}
|
||||||
{#if self && focus != null && focused}
|
{#if self && focus != null && focused}
|
||||||
<div class="spot" role="region" aria-label="Screen share" bind:this={frame}>
|
<div class="spot" role="region" aria-label="Screen share" bind:this={frame}>
|
||||||
@@ -256,6 +264,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if focus !== self.session}<button class="btn small" onclick={() => { if (focus != null) share.unwatch(focus); }}>Stop watching</button>
|
{#if focus !== self.session}<button class="btn small" onclick={() => { if (focus != null) share.unwatch(focus); }}>Stop watching</button>
|
||||||
{:else if videoOnly}
|
{:else if videoOnly}
|
||||||
|
{@render switcher()}
|
||||||
<span class="status" role="status" title="People watching your {share.kind}">{share.viewers} watching</span>
|
<span class="status" role="status" title="People watching your {share.kind}">{share.viewers} watching</span>
|
||||||
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
|
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -267,6 +276,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="pic" role="presentation" oncontextmenu={streamMenu}
|
<div class="pic" role="presentation" oncontextmenu={streamMenu}
|
||||||
ontouchstart={touchStart} ontouchend={touchEnd} ontouchmove={touchEnd} ontouchcancel={touchEnd}>
|
ontouchstart={touchStart} ontouchend={touchEnd} ontouchmove={touchEnd} ontouchcancel={touchEnd}>
|
||||||
|
<!-- Large, your own camera is shown as the others see it; only the small tile is a mirror -->
|
||||||
<video bind:this={spotVideo} use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
|
<video bind:this={spotVideo} use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
|
||||||
{#if focus !== self.session && focusWatch && focusWatch.state !== 'live'}
|
{#if focus !== self.session && focusWatch && focusWatch.state !== 'live'}
|
||||||
<p class="pending" class:bad={focusWatch.state === 'failed'}>{focusWatch.state === 'failed'
|
<p class="pending" class:bad={focusWatch.state === 'failed'}>{focusWatch.state === 'failed'
|
||||||
@@ -298,7 +308,7 @@
|
|||||||
aria-label={stream ? `${focus === u.session ? 'Hide' : 'Show'} the ${sharing || 'stream'} of ${store.displayName(u)}` : sharing && !isSelf ? `Watch the ${sharing} of ${store.displayName(u)}` : store.displayName(u)}>
|
aria-label={stream ? `${focus === u.session ? 'Hide' : 'Show'} the ${sharing || 'stream'} of ${store.displayName(u)}` : sharing && !isSelf ? `Watch the ${sharing} of ${store.displayName(u)}` : store.displayName(u)}>
|
||||||
{#if cam}
|
{#if cam}
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
<video class:mirror={isSelf} use:media={cam} autoplay playsinline muted></video>
|
<video class:mirror={isSelf && share.mirror} use:media={cam} autoplay playsinline muted></video>
|
||||||
{:else}
|
{:else}
|
||||||
<Avatar name={u.name} size={avatar} speaking={talking(u.session)} />
|
<Avatar name={u.name} size={avatar} speaking={talking(u.session)} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -366,6 +376,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if mine}
|
{#if mine}
|
||||||
|
{@render switcher()}
|
||||||
<span class="status" role="status" title="People watching your {share.kind}"><Icon name={share.kind} size={14} />{share.viewers} watching</span>
|
<span class="status" role="status" title="People watching your {share.kind}"><Icon name={share.kind} size={14} />{share.viewers} watching</span>
|
||||||
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
|
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+30
-1
@@ -22,7 +22,7 @@ async function launch() {
|
|||||||
const app = await electron.launch({
|
const app = await electron.launch({
|
||||||
executablePath: electronPath as unknown as string,
|
executablePath: electronPath as unknown as string,
|
||||||
args: [root, `--user-data-dir=${mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'))}`, '--ozone-platform=x11',
|
args: [root, `--user-data-dir=${mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'))}`, '--ozone-platform=x11',
|
||||||
'--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
|
'--use-fake-device-for-media-stream=device-count=2', '--use-fake-ui-for-media-stream'],
|
||||||
env: env as Record<string, string>
|
env: env as Record<string, string>
|
||||||
});
|
});
|
||||||
const page = await app.firstWindow();
|
const page = await app.firstWindow();
|
||||||
@@ -311,6 +311,35 @@ try {
|
|||||||
await bob.page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
|
await bob.page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
|
||||||
if (process.env.SHOTS_DIR) await bob.page.screenshot({ path: `${process.env.SHOTS_DIR}/cams.png` });
|
if (process.env.SHOTS_DIR) await bob.page.screenshot({ path: `${process.env.SHOTS_DIR}/cams.png` });
|
||||||
|
|
||||||
|
// Switching cameras swaps the picture on the running stream. Chromium's second fake camera
|
||||||
|
// never delivers frames to an encoder (not even as the first camera), so the viewer is
|
||||||
|
// checked after switching there and back.
|
||||||
|
const ownCamera = () => alice.page.evaluate(() => ((document.querySelector('.stage .tile.self video') as HTMLVideoElement).srcObject as MediaStream).getVideoTracks()[0].label);
|
||||||
|
const firstCamera = await ownCamera();
|
||||||
|
const switchTo = async (different: boolean) => {
|
||||||
|
await alice.page.locator('.stage').getByRole('button', { name: 'Switch camera' }).click();
|
||||||
|
await alice.page.waitForFunction(([was, differs]) => {
|
||||||
|
const v = document.querySelector('.stage .tile.self video') as HTMLVideoElement | null;
|
||||||
|
const track = (v?.srcObject as MediaStream | null)?.getVideoTracks()[0];
|
||||||
|
return !!track && (track.label !== was) === differs && track.readyState === 'live' && v!.videoWidth > 0;
|
||||||
|
}, [firstCamera, different] as const, { timeout: 15000 });
|
||||||
|
await alice.page.getByRole('button', { name: 'Switch camera' }).and(alice.page.locator(':enabled')).waitFor();
|
||||||
|
};
|
||||||
|
await switchTo(true);
|
||||||
|
const secondCamera = await ownCamera();
|
||||||
|
await switchTo(false);
|
||||||
|
const fresh = await bob.page.evaluate(n => new Promise<number>(resolve => {
|
||||||
|
const v = document.querySelector(`.stage .tile[aria-label*="${n}"] video`) as HTMLVideoElement;
|
||||||
|
let count = 0;
|
||||||
|
const tick = () => { if (++count >= 8) resolve(count); else v.requestVideoFrameCallback(tick); };
|
||||||
|
v.requestVideoFrameCallback(tick);
|
||||||
|
setTimeout(() => resolve(count), 12000);
|
||||||
|
}), aliceName);
|
||||||
|
assert.ok(fresh >= 8, `viewer got ${fresh} new frames after switching cameras`);
|
||||||
|
await alice.page.locator('.stage .status', { hasText: '1 watching' }).waitFor();
|
||||||
|
assert.equal(await alice.page.locator('.bar.bad').count(), 0);
|
||||||
|
console.log(`ok: camera switched while sharing (${firstCamera} to ${secondCamera} and back), viewer kept the stream`);
|
||||||
|
|
||||||
// A click enlarges one camera; the statistics show it came through the relay
|
// A click enlarges one camera; the statistics show it came through the relay
|
||||||
await bob.page.locator('.stage .tile', { hasText: aliceName }).click();
|
await bob.page.locator('.stage .tile', { hasText: aliceName }).click();
|
||||||
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
|
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
|
||||||
|
|||||||
Reference in New Issue
Block a user