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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user