Add screen sharing between mumh5 users (picture only)

- Signals travel as Mumble plugin data (compressed, chunked under Murmur's
  1000 byte limit); the picture goes directly to each viewer over WebRTC
- Toolbar button with a source picker in the desktop app, an indicator next to
  people who share, click to watch, a view above the chat with full screen
- Late joiners learn about running streams; moving, stopping or disconnecting
  ends them for viewers
- A note about IP addresses before the first use, optional STUN server setting
- Unit tests for the signal codec and an E2E with two app instances

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:58:33 +02:00
co-authored by Claude Opus 5.5
parent 82972438fe
commit 0cce3c97c2
23 changed files with 756 additions and 4 deletions
+15
View File
@@ -11,6 +11,8 @@
import { drag } from '../lib/drag.svelte.ts';
import { sounds } from '../lib/audio/sounds.svelte.ts';
import { renderIncoming } from '../lib/html.ts';
import { share } from '../lib/share.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import type { Channel } from '../core/client.ts';
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
@@ -154,6 +156,17 @@
<Avatar name={u.name} size={22} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
<span class="label" class:talking={voice.talking[u.session] || (isSelf && voice.transmitting)}>{store.displayName(u)}</span>
{#if u.userId != null}<span class="reg" title="Registered">R</span>{/if}
{#if session.sharing[u.session]}
{#if isSelf}
<span class="state sharing" title="You are sharing your screen"><Icon name="screen" size={14} /></span>
{:else}
<!-- A button cannot sit inside the row's button -->
<span class="state sharing" role="button" tabindex="0" title="Sharing their screen. Click to watch" aria-label="Watch the screen of {store.displayName(u)}"
onclick={e => { e.stopPropagation(); clearTimeout(timer); share.watch(sessions.active, u.session); }}
ondblclick={e => e.stopPropagation()}
onkeydown={e => { if (e.key === 'Enter') { e.stopPropagation(); share.watch(sessions.active, u.session); } }}><Icon name="screen" size={14} /></span>
{/if}
{/if}
{#if u.comment || u.commentHash}<span class="state" title="Has a description"><Icon name="message" size={12} /></span>{/if}
{#if u.prioritySpeaker}<span class="state" title="Priority speaker"><Icon name="channel" size={14} /></span>{/if}
{#if u.recording}<span class="rec" title="Recording">REC</span>{/if}
@@ -207,5 +220,7 @@
.reg { font-size: 10px; font-weight: 700; color: var(--accent-hover); border: 1px solid var(--accent); padding: 0 3px; line-height: 14px; }
.state { color: var(--text-faint); display: inline-flex; }
.state.admin { color: var(--danger); }
.state.sharing { color: var(--speaking); padding: 3px; }
.state.sharing[role=button]:hover { background: var(--bg-active); }
.rec { font-size: 10px; font-weight: 700; color: var(--danger); border: 1px solid var(--danger); padding: 0 3px; }
</style>
+2
View File
@@ -3,6 +3,7 @@
import ChatLog from './ChatLog.svelte';
import Composer from './Composer.svelte';
import VoiceStage from './VoiceStage.svelte';
import ShareView from './ShareView.svelte';
import { ui } from '../lib/ui.svelte.ts';
import { session } from '../lib/session.svelte.ts';
@@ -28,6 +29,7 @@
</header>
{#if showStage && session.status === 'connected'}<VoiceStage />{/if}
{#if session.status === 'connected'}<ShareView />{/if}
{#if session.status === 'idle' && session.disconnectInfo}
{@const d = session.disconnectInfo}
+5
View File
@@ -59,6 +59,10 @@
{#if tab === 'voice'}
<VoiceSettings />
<h3>Screen sharing</h3>
<label for="s-stun">STUN server <span class="opt">(optional)</span></label>
<input id="s-stun" bind:value={s.stunServer} onchange={() => store.saveSettings()} placeholder="stun.example.com:3478" autocomplete="off" />
<p class="help">Screen sharing connects you directly to the people watching. Behind most home routers that needs a STUN server to find a route; it learns your IP address but never sees the stream. Empty means no outside server is contacted, which works on the same network and often over IPv6.</p>
{:else if tab === 'sounds'}
<SoundSettings />
{:else if tab === 'appearance'}
@@ -119,6 +123,7 @@
.tabs button { padding: 8px 14px; color: var(--text-dim); font-weight: 600; white-space: nowrap; }
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
h3 { margin: 22px 0 0; font-size: 14px; }
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
.help { margin: 10px 0 0; font-size: 13px; color: var(--text-dim); }
.row { display: flex; align-items: flex-end; gap: 8px; }
.row > div { flex: 1; }
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import Modal from './Modal.svelte';
import { share } from '../lib/share.svelte.ts';
// Desktop app only: choose the screen or window to share
const picker = $derived(share.picker);
</script>
{#if picker}
<Modal title="Share your screen" onclose={() => picker.choose(null)} width={720}>
<p class="help">Pick a screen or a window. mumh5 users in your channel can then watch it.</p>
<ul class="sources">
{#each picker.sources as s (s.id)}
<li>
<button onclick={() => picker.choose(s.id)}>
{#if s.thumbnail}<img src={s.thumbnail} alt="" />{:else}<span class="blank"></span>{/if}
<span class="name">{s.name}</span>
</button>
</li>
{/each}
</ul>
{#snippet footer()}
<button class="btn" onclick={() => picker.choose(null)}>Cancel</button>
{/snippet}
</Modal>
{/if}
<style>
.help { margin: 8px 0 0; font-size: 13px; color: var(--text-dim); }
.sources { list-style: none; margin: 12px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
.sources button { width: 100%; display: flex; flex-direction: column; gap: 6px; padding: 8px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); }
.sources button:hover { border-color: var(--accent); }
img, .blank { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; background: #000; display: block; }
.name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { share } from '../lib/share.svelte.ts';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
// Above the chat: the stream being watched, and a note while we are sharing ourselves
const watching = $derived(share.watching && share.watching.host.server?.id === session.server?.id ? share.watching : null);
const sharer = $derived(watching ? session.user(watching.session) : undefined);
const mine = $derived(share.stream && share.host?.server?.id === session.server?.id);
let video = $state<HTMLVideoElement>();
let frame = $state<HTMLElement>();
$effect(() => { if (video) video.srcObject = watching?.stream ?? null; });
function fullscreen() {
if (document.fullscreenElement) document.exitFullscreen();
else frame?.requestFullscreen().catch(() => {});
}
</script>
{#if mine}
<div class="bar" role="status">
<Icon name="screen" size={16} />
<span>You are sharing your screen{share.viewers ? `, ${share.viewers} watching` : ''}</span>
<button class="btn" onclick={() => share.stop()}>Stop sharing</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}
{#if watching}
<section class="watch" aria-label="Screen share" bind:this={frame}>
<header>
<Icon name="screen" size={16} />
<span class="who">{sharer ? store.displayName(sharer) : 'Screen share'}</span>
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button>
<button class="icon-btn" title="Stop watching" aria-label="Stop watching" onclick={() => share.unwatch()}><Icon name="x" size={16} /></button>
</header>
<div class="stagebox">
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={video} autoplay playsinline muted ondblclick={fullscreen}></video>
{#if watching.state === 'connecting'}<p class="note">Connecting...</p>
{:else if watching.state === 'failed'}<p class="note">Could not connect. You and the other person may both be behind routers that block direct connections; a STUN server in Settings, Voice can help.</p>{/if}
</div>
</section>
{/if}
<style>
.bar { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 13px; background: var(--bg-1); border-bottom: 1px solid var(--line); color: var(--text-dim); }
.bar span { flex: 1; min-width: 0; }
.bar .btn { padding: 4px 10px; font-size: 12px; }
.bar.bad { color: var(--danger); }
.watch { flex: none; display: flex; flex-direction: column; max-height: 62%; min-height: 160px; background: #000; border-bottom: 1px solid var(--line); }
.watch header { flex: none; display: flex; align-items: center; gap: 8px; padding: 2px 6px 2px 12px; background: var(--bg-1); color: var(--text-dim); font-size: 13px; }
.who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-weight: 600; }
.stagebox { position: relative; flex: 1; min-height: 0; display: flex; }
video { width: 100%; height: 100%; min-height: 0; object-fit: contain; background: #000; display: block; }
.note { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; text-align: center; padding: 16px; color: #ddd; font-size: 14px; }
.watch:fullscreen { max-height: none; }
</style>
+14
View File
@@ -9,6 +9,7 @@
import { PERM } from '../core/client.ts';
import ResizeHandle from './ResizeHandle.svelte';
import { voice } from '../lib/audio/voice.svelte.ts';
import { share } from '../lib/share.svelte.ts';
// resizable: false in the phone drawer, which keeps a fixed width
// handleEdge: which side the resize handle sits on (left when the list is on the right, classic layout)
@@ -50,6 +51,14 @@
ui.panelOpen = true;
}
// Sharing runs on one server at a time; the button stops it from anywhere
const sharingHere = $derived(!!share.stream);
const canShare = $derived(session.status === 'connected' && share.supported(sessions.active));
function toggleShare() {
if (share.stream) share.stop();
else share.start(sessions.active);
}
// One button for the whole tree: open everything, then close everything
let allOpen = $state(false);
function toggleAll() {
@@ -159,6 +168,10 @@
onclick={() => session.setSelfDeaf(!deafened)} oncontextmenu={e => openQuick(e, 'output')}>
<Icon name={deafened ? 'headphones-off' : 'headphones'} size={18} />
</button>
<button class="icon-btn" class:live={sharingHere} title={sharingHere ? 'Stop sharing your screen' : 'Share your screen with your channel'}
aria-label={sharingHere ? 'Stop sharing your screen' : 'Share your screen'} disabled={!sharingHere && !canShare} onclick={toggleShare}>
<Icon name="screen" size={18} />
</button>
<span class="sep"></span>
<button class="icon-btn" title="Change your description" aria-label="Change your description" disabled={!self} onclick={editDescription}>
<Icon name="message" size={18} />
@@ -216,5 +229,6 @@
.who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who span { font-size: 12px; color: var(--text-dim); }
.info, .info:hover { color: var(--warn); }
.live, .live:hover { color: var(--speaking); }
.offline-who { flex: 1 1 110px; padding: 2px 2px 2px 6px; color: var(--text-dim); }
</style>