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 b828cfb..a422c49 100644 --- a/src/lib/share.svelte.ts +++ b/src/lib/share.svelte.ts @@ -43,6 +43,12 @@ class ScreenShare { host = $state.raw(null); stream = $state.raw(null); kind = $state('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); // Streams we receive: one screen at a time, and any number of cameras next to it watches = $state.raw([]); @@ -142,6 +148,9 @@ 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); @@ -149,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/ShareDialog.svelte b/src/ui/ShareDialog.svelte index f056d09..94d8428 100644 --- a/src/ui/ShareDialog.svelte +++ b/src/ui/ShareDialog.svelte @@ -220,7 +220,7 @@ Preview
- + {#if !preview}

Nothing chosen yet

{/if}
@@ -280,6 +280,8 @@ .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 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; } .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; } diff --git a/src/ui/VoiceStage.svelte b/src/ui/VoiceStage.svelte index a515202..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} @@ -267,6 +276,7 @@ {:else}