From 06a185d77848c72500ba91ac9fcb90a89382fa8c Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Fri, 2 Oct 2026 01:00:04 +0200 Subject: [PATCH] Switch between cameras while sharing The picture is swapped on the running stream, so viewers stay connected. The new camera is opened first where the device allows two at once; phones close the old one first. Co-Authored-By: Claude Opus 5.5 --- src/lib/icons.ts | 1 + src/lib/share.svelte.ts | 59 ++++++++++++++++++++++++++++++++++++++++ src/ui/VoiceStage.svelte | 10 +++++++ test/e2e/share.e2e.ts | 31 ++++++++++++++++++++- 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/lib/icons.ts b/src/lib/icons.ts index ec24d21..8695eff 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -6,6 +6,7 @@ export const ICON_PATHS: Record = { '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', plus: 'M12 5v14M5 12h14', + switch: 'M4 8h13l-3-3M20 16H7l3 3', camera: 'M2 6h14v12H2zM16 10l6-3v10l-6-3', screen: 'M3 4h18v12H3zM8 20h8M12 16v4', popout: 'M14 4h6v6M20 4l-9 9M18 14v6H4V6h6', diff --git a/src/lib/share.svelte.ts b/src/lib/share.svelte.ts index 84128c4..a422c49 100644 --- a/src/lib/share.svelte.ts +++ b/src/lib/share.svelte.ts @@ -46,6 +46,9 @@ class ScreenShare { // 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); // Streams we receive: one screen at a time, and any number of cameras next to it watches = $state.raw([]); @@ -146,6 +149,8 @@ class ScreenShare { this.usesVirtualMic = usesVirtualMic; 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.stream = stream; this.send(s, this.others(s), SHARE.on, kind); @@ -153,6 +158,60 @@ class ScreenShare { sounds.play('shareStarted'); } + // The next camera of this device (front and back on a phone), without interrupting the stream + async switchCamera(): Promise { + 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 { this.dropPeers(); this.stream?.getTracks().forEach(t => t.stop()); diff --git a/src/ui/VoiceStage.svelte b/src/ui/VoiceStage.svelte index 6650c05..68ccdac 100644 --- a/src/ui/VoiceStage.svelte +++ b/src/ui/VoiceStage.svelte @@ -237,6 +237,14 @@ {#if voice.error && session.status === 'connected'}{/if} {#if share.error}{/if} +{#snippet switcher()} + {#if mine && share.kind === 'camera' && share.cameras > 1} + + {/if} +{/snippet} + {#snippet spot()} {#if self && focus != null && focused}
@@ -256,6 +264,7 @@ {/if} {#if focus !== self.session} {:else if videoOnly} + {@render switcher()} {share.viewers} watching {/if} @@ -367,6 +376,7 @@ {/if} {#if mine} + {@render switcher()} {share.viewers} watching {/if} diff --git a/test/e2e/share.e2e.ts b/test/e2e/share.e2e.ts index 6be9c46..358668e 100644 --- a/test/e2e/share.e2e.ts +++ b/test/e2e/share.e2e.ts @@ -22,7 +22,7 @@ async function launch() { const app = await electron.launch({ executablePath: electronPath as unknown as string, 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 }); const page = await app.firstWindow(); @@ -311,6 +311,35 @@ try { 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` }); + // 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(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 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 });