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 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 01:00:04 +02:00
co-authored by Claude Opus 5.5
parent c990593f9f
commit 06a185d778
4 changed files with 100 additions and 1 deletions
+1
View File
@@ -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',
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',
+59
View File
@@ -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<Watching[]>([]);
@@ -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<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 {
this.dropPeers();
this.stream?.getTracks().forEach(t => t.stop());
+10
View File
@@ -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 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()}
{#if self && focus != null && focused}
<div class="spot" role="region" aria-label="Screen share" bind:this={frame}>
@@ -256,6 +264,7 @@
{/if}
{#if focus !== self.session}<button class="btn small" onclick={() => { if (focus != null) share.unwatch(focus); }}>Stop watching</button>
{:else if videoOnly}
{@render switcher()}
<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>
{/if}
@@ -367,6 +376,7 @@
</button>
{/if}
{#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>
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
{/if}