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
+3 -1
View File
@@ -15,6 +15,7 @@
import ChannelDialog from './ui/ChannelDialog.svelte';
import PublicServers from './ui/PublicServers.svelte';
import ServerInfoDialog from './ui/ServerInfoDialog.svelte';
import SharePicker from './ui/SharePicker.svelte';
import ResizeHandle from './ui/ResizeHandle.svelte';
import { identities } from './lib/identities.svelte.ts';
import { session } from './lib/session.svelte.ts';
@@ -129,7 +130,8 @@
{#if ui.channelDialog && session.status === 'connected'}
{#key ui.channelDialog}<ChannelDialog {...ui.channelDialog} onclose={() => (ui.channelDialog = null)} />{/key}
{/if}
{#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => (ui.prompt = null)} />{/if}
<SharePicker />
{#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => { const p = ui.prompt; ui.prompt = null; p?.oncancel?.(); }} />{/if}
<ContextMenu />
<style>
+97
View File
@@ -0,0 +1,97 @@
// Screen-share signalling between mumh5 clients, carried as Mumble plugin data so it works on
// any server and other clients ignore it. Murmur drops plugin data over 1000 bytes, so session
// descriptions are compressed and split into chunks.
export const SHARE_DATA_ID = 'mumh5.share';
export const SHARE = {
on: 1, // I am sharing my screen
off: 2, // I stopped
who: 3, // I just got here: who is sharing?
watch: 4, // let me watch
leave: 5, // I stopped watching
offer: 6, // session description from the sharer
answer: 7 // session description from the viewer
} as const;
export type ShareType = typeof SHARE[keyof typeof SHARE];
const CHUNK = 900;
const MAX_CHUNKS = 32;
const MAX_TEXT = 256 * 1024;
let nextId = 0;
async function deflate(bytes: Uint8Array): Promise<Uint8Array> {
const out = new Blob([bytes as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new CompressionStream('deflate-raw'));
return new Uint8Array(await new Response(out).arrayBuffer());
}
// Stops reading once the output passes the cap, so a tiny message cannot expand without bound
async function inflate(bytes: Uint8Array): Promise<Uint8Array | null> {
const reader = new Blob([bytes as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new DecompressionStream('deflate-raw')).getReader();
const parts: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > MAX_TEXT) { await reader.cancel(); return null; }
parts.push(value);
}
} catch {
return null;
}
const all = new Uint8Array(size);
let at = 0;
for (const p of parts) { all.set(p, at); at += p.length; }
return all;
}
// One packet for a bare signal, several for a signal with text: [type, id, index, total, ...data]
export async function encodeShare(type: ShareType, text?: string): Promise<Uint8Array[]> {
if (text == null) return [new Uint8Array([type])];
const data = await deflate(new TextEncoder().encode(text));
const total = Math.max(1, Math.ceil(data.length / CHUNK));
if (total > MAX_CHUNKS) throw new Error('Signal too large');
const id = nextId = (nextId + 1) & 255;
return Array.from({ length: total }, (_, i) => {
const part = data.subarray(i * CHUNK, (i + 1) * CHUNK);
const packet = new Uint8Array(4 + part.length);
packet.set([type, id, i, total]);
packet.set(part, 4);
return packet;
});
}
export interface ShareSignal { type: ShareType; text?: string }
// Puts chunked signals back together, per sender
export class ShareAssembler {
private pending = new Map<string, { parts: (Uint8Array | undefined)[]; got: number; at: number }>();
async push(sender: number, packet: Uint8Array, now = Date.now()): Promise<ShareSignal | null> {
const type = packet[0] as ShareType;
if (!Object.values(SHARE).includes(type)) return null;
if (packet.length === 1) return { type };
if (packet.length < 4) return null;
const [, id, index, total] = packet;
if (!total || total > MAX_CHUNKS || index >= total) return null;
// Abandoned transfers must not pile up
for (const [k, v] of this.pending) if (now - v.at > 15000) this.pending.delete(k);
if (this.pending.size > 64) this.pending.clear();
const key = `${sender}:${type}:${id}`;
let entry = this.pending.get(key);
if (!entry || entry.parts.length !== total) {
entry = { parts: new Array(total), got: 0, at: now };
this.pending.set(key, entry);
}
if (!entry.parts[index]) entry.got++;
entry.parts[index] = packet.slice(4);
if (entry.got < total) return null;
this.pending.delete(key);
const joined = new Uint8Array(entry.parts.reduce((n, p) => n + p!.length, 0));
let at = 0;
for (const p of entry.parts) { joined.set(p!, at); at += p!.length; }
const plain = await inflate(joined);
return plain ? { type, text: new TextDecoder().decode(plain) } : null;
}
}
+3 -1
View File
@@ -1,4 +1,5 @@
import { session } from './session.svelte.ts';
import { session, sessions } from './session.svelte.ts';
import { share } from './share.svelte.ts';
import { ui } from './ui.svelte.ts';
import { store } from './settings.svelte.ts';
import { voice, userKey } from './audio/voice.svelte.ts';
@@ -67,6 +68,7 @@ export function userMenu(u: User): MenuItem[] {
);
if (u.userId == null) items.push({ label: 'Register on this server', action: () => session.register(u.session) });
} else {
if (session.sharing[u.session]) items.push({ label: 'Watch screen', action: () => share.watch(sessions.active, u.session) });
items.push(
{ label: 'Send message', action: () => session.selectView(`dm:${u.session}`) },
{ label: 'Mute for me', checked: !!voice.settings.localMutes[userKey(u)], action: () => voice.setLocalMute(u, !voice.settings.localMutes[userKey(u)]) },
+2
View File
@@ -6,6 +6,8 @@ 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',
screen: 'M3 4h18v12H3zM8 20h8M12 16v4',
expand: 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5',
filter: 'M3 4h18l-7 8v7l-4 2v-9z',
info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 11v6M12 7h.01',
'arrow-right': 'M5 12h14M13 6l6 6-6 6',
+5
View File
@@ -29,6 +29,10 @@ interface NativeApi extends PlatformApi {
pingServer(host: string, port: number): Promise<PingResult | null>;
onContextMenu(fn: (params: ContextParams) => void): void;
editAction(action: string, arg?: unknown): void;
// Screens and windows that can be shared; pick one before calling getDisplayMedia
// chosen: the system's own picker already asked (Wayland), so `sources` is the one answer
screenSources(): Promise<{ sources: ScreenSource[]; chosen: boolean }>;
pickScreenSource(id: string): void;
open(connId: string, host: string, port: number, identityId?: string): Promise<void>;
send(connId: string, bytes: Uint8Array): void;
close(connId: string): void;
@@ -53,6 +57,7 @@ export interface ContextParams {
editFlags: { canUndo: boolean; canRedo: boolean; canCut: boolean; canCopy: boolean; canPaste: boolean; canSelectAll: boolean };
}
export interface ScreenSource { id: string; name: string; thumbnail: string }
export interface PublicServer { name: string; host: string; port: number; url: string; country: string; countryCode: string; continent: string; ca: boolean }
export interface PingResult { ping: number; users: number; maxUsers: number; bandwidth: number; version: string }
+18
View File
@@ -1,6 +1,7 @@
import { createCodec } from '../core/proto.ts';
import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts';
import { native, openTransport } from './native.ts';
import { share } from './share.svelte.ts';
import type { ServerCertInfo } from '../core/transport.ts';
import { store, type SavedServer } from './settings.svelte.ts';
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts';
@@ -60,6 +61,8 @@ export class Session {
uploads = $state<UploadJob[]>([]);
certPrompt = $state<CertPrompt | null>(null);
rtt = $state(0);
// Who in our channel is sharing their screen, by session (mumh5 clients only)
sharing = $state<Record<number, boolean>>({});
// Why the last connection ended, shown with a Reconnect button (null after a normal disconnect)
disconnectInfo = $state<{ kind: 'kicked' | 'banned' | 'lost' | 'rejected'; by: string; reason: string } | null>(null);
private removedBy: { kind: 'kicked' | 'banned'; by: string; reason: string } | null = null;
@@ -288,6 +291,8 @@ export class Session {
const client = this.client;
this.client = null;
client?.disconnect();
share.closed(this);
this.sharing = {};
sessions.onClosed(this);
this.status = 'idle';
this.tick++;
@@ -312,8 +317,10 @@ export class Session {
// What we may do per channel, for menus (not rate limited by the server)
for (const id of client.channels.keys()) client.requestPermissions(id);
if (client.welcomeText) this.system(this.view, client.welcomeText, true);
share.entered(this);
bump();
});
client.on('pluginData', (sender, dataId, data) => { if (live()) share.receive(this, sender, dataId, data); });
client.on('channel', ch => {
if (live() && client.synced && !client.permissions.has(ch.id)) client.requestPermissions(ch.id);
bump();
@@ -337,6 +344,10 @@ export class Session {
const self = client.self;
const before = this.lastChannel.get(user.session);
this.lastChannel.set(user.session, user.channelId);
if (client.synced && self && (isNew || changed.includes('channelId'))) {
if (user.session === self.session) share.entered(this);
else share.userMoved(this, user);
}
if (client.synced && self && this.isVoice()) this.userSound(user.session === self.session, user, changed, actor, isNew, before, self.channelId);
if (client.synced && self) {
if (isNew) {
@@ -366,6 +377,7 @@ export class Session {
this.removedBy = { kind: ban ? 'banned' : 'kicked', by, reason };
}
const self = client.self;
share.userMoved(this, user, true);
if (self && user.session !== self.session && this.isVoice() && user.channelId === self.channelId) {
const byOther = actor != null && actor !== user.session;
sounds.play(ban ? 'userBanned' : byOther ? 'userKicked' : 'userDisconnected');
@@ -675,6 +687,12 @@ export class Session {
// ─── Voice state and moderation ─────────────────────────────────────────────
setSharing(session: number, on: boolean): void {
if (on) this.sharing[session] = true;
else delete this.sharing[session];
}
clearSharing(): void { this.sharing = {}; }
// The choice is remembered, so it also works while disconnected and carries to the next connect
setSelfMute(v: boolean) {
sessions.mute = v;
+4
View File
@@ -24,6 +24,8 @@ export interface Settings {
theme: ThemeId;
publicListUrl: string; // where the public server list comes from
arrangement: Arrangement;
stunServer: string; // helps screen sharing connect through routers; empty contacts nobody
shareNoticeSeen: boolean; // the note that screen sharing reveals IP addresses was confirmed
hints: boolean; // tooltips (title attributes) when resting the pointer on buttons
}
@@ -52,6 +54,8 @@ const defaults: Settings = {
theme: 'dark',
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0',
arrangement: 'side',
stunServer: '',
shareNoticeSeen: false,
hints: false
};
+288
View File
@@ -0,0 +1,288 @@
// Screen sharing between mumh5 clients in the same channel. Mumble has no video, so the
// picture goes directly from the sharer to each viewer over WebRTC; only the setup messages
// travel through the Mumble server (as plugin data, see core/share-signal.ts). The server has
// checked the sender's certificate, so a stream is known to come from that user.
import { SHARE, SHARE_DATA_ID, encodeShare, ShareAssembler, type ShareType } from '../core/share-signal.ts';
import type { User } from '../core/client.ts';
import type { Session } from './session.svelte.ts';
import { desktop, type ScreenSource } from './native.ts';
import { store } from './settings.svelte.ts';
import { ui } from './ui.svelte.ts';
// Every viewer is a separate upload from the sharer
const MAX_VIEWERS = 8;
const MAX_BITRATE = 4_000_000;
export interface Watching {
host: Session;
session: number;
stream: MediaStream | null;
state: 'connecting' | 'live' | 'failed';
}
class ScreenShare {
// The server we are sharing on, and what we send
host = $state.raw<Session | null>(null);
stream = $state.raw<MediaStream | null>(null);
viewers = $state(0);
watching = $state.raw<Watching | null>(null);
error = $state('');
// Source choice in the desktop app (the browser brings its own picker)
picker = $state.raw<{ sources: ScreenSource[]; choose: (id: string | null) => void } | null>(null);
private peers = new Map<number, RTCPeerConnection>();
private watchPc: RTCPeerConnection | null = null;
private watchTimer: ReturnType<typeof setTimeout> | undefined;
private assemblers = new WeakMap<Session, ShareAssembler>();
// Plugin data needs a 1.4 server; older ones would not pass the signals on
supported(s: Session): boolean {
return !!s.client && s.client.serverVersionNum >= 0x010400 && typeof RTCPeerConnection !== 'undefined';
}
private iceServers(): RTCIceServer[] {
const stun = store.settings.stunServer.trim();
return stun ? [{ urls: /^stuns?:/.test(stun) ? stun : `stun:${stun}` }] : [];
}
private others(s: Session): number[] {
const self = s.client?.self;
if (!self) return [];
return [...s.client!.users.values()].filter(u => u.session !== self.session && u.channelId === self.channelId).map(u => u.session);
}
private inMyChannel(s: Session, session: number): boolean {
const self = s.client?.self;
return !!self && s.client!.users.get(session)?.channelId === self.channelId;
}
private async send(s: Session, to: number[], type: ShareType, text?: string): Promise<void> {
if (!to.length) return;
for (const packet of await encodeShare(type, text)) s.client?.sendPluginData(to, SHARE_DATA_ID, packet);
}
// Sharer and viewer connect directly, so each learns the other's address; say so once
private notice(): Promise<boolean> {
if (store.settings.shareNoticeSeen) return Promise.resolve(true);
return new Promise(resolve => {
let answered = false;
ui.prompt = {
title: 'Screen sharing', label: '', confirm: 'Continue', input: 'none',
text: 'Screen sharing connects you directly to the other person. You see each other\'s IP address, like in a video call. Only mumh5 users in your channel can watch.',
onsubmit: () => { answered = true; store.settings.shareNoticeSeen = true; store.saveSettings(); resolve(true); },
oncancel: () => { if (!answered) resolve(false); }
};
});
}
private async capture(): Promise<MediaStream> {
if (desktop) {
const { sources, chosen } = await desktop.screenSources();
// Cancelled in the system's picker
if (!sources.length) throw new Error('');
const id = chosen ? sources[0].id
: await new Promise<string | null>(choose => { this.picker = { sources, choose }; });
this.picker = null;
if (!id) throw new Error('');
desktop.pickScreenSource(id);
}
return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: false });
}
async start(s: Session): Promise<void> {
if (this.stream || !this.supported(s) || !(await this.notice())) return;
this.error = '';
let stream: MediaStream;
try {
stream = await this.capture();
} catch (e) {
// Cancelling the picker is not an error worth showing
const msg = (e as Error).message;
if (msg && (e as Error).name !== 'NotAllowedError') this.error = `Could not capture the screen: ${msg}`;
return;
}
// Disconnected while the picker was open
if (!s.client) return stream.getTracks().forEach(t => t.stop());
for (const t of stream.getVideoTracks()) {
t.contentHint = 'detail';
// The system's "stop sharing" control
t.addEventListener('ended', () => this.stop());
}
this.host = s;
this.stream = stream;
this.send(s, this.others(s), SHARE.on);
s.setSharing(s.client.session!, true);
}
stop(): void {
const s = this.host;
if (s?.client) {
this.send(s, this.others(s), SHARE.off);
if (s.client.session != null) s.setSharing(s.client.session, false);
}
this.dropPeers();
this.stream?.getTracks().forEach(t => t.stop());
this.stream = null;
this.host = null;
}
private dropPeers(): void {
for (const pc of this.peers.values()) pc.close();
this.peers.clear();
this.viewers = 0;
}
private dropPeer(session: number): void {
this.peers.get(session)?.close();
this.peers.delete(session);
this.viewers = this.peers.size;
}
async watch(s: Session, session: number): Promise<void> {
if (!this.supported(s) || !s.sharing[session] || !(await this.notice())) return;
this.unwatch();
this.watching = { host: s, session, stream: null, state: 'connecting' };
this.send(s, [session], SHARE.watch);
this.watchTimer = setTimeout(() => this.setWatchState('failed'), 20000);
}
unwatch(): void {
const w = this.watching;
if (!w) return;
if (w.host.client) this.send(w.host, [w.session], SHARE.leave);
this.endWatch();
}
private endWatch(): void {
clearTimeout(this.watchTimer);
this.watchPc?.close();
this.watchPc = null;
this.watching = null;
}
private setWatchState(state: Watching['state'], stream?: MediaStream): void {
const w = this.watching;
if (!w) return;
if (state !== 'connecting') clearTimeout(this.watchTimer);
this.watching = { ...w, state, stream: stream ?? w.stream };
}
// Candidates travel inside the description, so wait for them instead of sending each one
private gathered(pc: RTCPeerConnection): Promise<void> {
if (pc.iceGatheringState === 'complete') return Promise.resolve();
return new Promise(resolve => {
const done = () => { clearTimeout(timer); pc.removeEventListener('icegatheringstatechange', check); resolve(); };
const check = () => { if (pc.iceGatheringState === 'complete') done(); };
const timer = setTimeout(done, 3000);
pc.addEventListener('icegatheringstatechange', check);
});
}
private async offerTo(s: Session, viewer: number): Promise<void> {
const stream = this.stream;
if (!stream || this.host !== s) return;
this.dropPeer(viewer);
if (this.peers.size >= MAX_VIEWERS) return;
const pc = new RTCPeerConnection({ iceServers: this.iceServers() });
this.peers.set(viewer, pc);
this.viewers = this.peers.size;
pc.addEventListener('connectionstatechange', () => {
if ((pc.connectionState === 'failed' || pc.connectionState === 'closed') && this.peers.get(viewer) === pc) this.dropPeer(viewer);
});
for (const track of stream.getTracks()) {
const sender = pc.addTrack(track, stream);
if (track.kind !== 'video') continue;
const params = sender.getParameters();
// Keep the picture sharp and let the frame rate drop when the connection is tight
params.degradationPreference = 'maintain-resolution';
for (const enc of params.encodings ?? []) enc.maxBitrate = MAX_BITRATE;
await sender.setParameters(params).catch(() => {});
}
await pc.setLocalDescription(await pc.createOffer());
await this.gathered(pc);
if (this.peers.get(viewer) === pc && pc.localDescription) await this.send(s, [viewer], SHARE.offer, pc.localDescription.sdp);
}
private async answerTo(s: Session, sharer: number, sdp: string): Promise<void> {
const w = this.watching;
if (!w || w.host !== s || w.session !== sharer) return;
this.watchPc?.close();
const pc = new RTCPeerConnection({ iceServers: this.iceServers() });
this.watchPc = pc;
pc.addEventListener('track', e => { if (this.watchPc === pc) this.setWatchState('live', e.streams[0]); });
pc.addEventListener('connectionstatechange', () => {
if (pc.connectionState === 'failed' && this.watchPc === pc) this.setWatchState('failed');
});
try {
await pc.setRemoteDescription({ type: 'offer', sdp });
await pc.setLocalDescription(await pc.createAnswer());
await this.gathered(pc);
if (this.watchPc === pc && pc.localDescription) await this.send(s, [sharer], SHARE.answer, pc.localDescription.sdp);
} catch {
if (this.watchPc === pc) this.setWatchState('failed');
}
}
// ─── Called by the session ────────────────────────────────────────────────
async receive(s: Session, sender: number, dataId: string, data: Uint8Array): Promise<void> {
if (dataId !== SHARE_DATA_ID) return;
let assembler = this.assemblers.get(s);
if (!assembler) this.assemblers.set(s, assembler = new ShareAssembler());
const msg = await assembler.push(sender, data);
// Only people in our channel take part
if (!msg || !s.client || !this.inMyChannel(s, sender)) return;
switch (msg.type) {
case SHARE.on: s.setSharing(sender, true); break;
case SHARE.off:
s.setSharing(sender, false);
if (this.watching?.host === s && this.watching.session === sender) this.endWatch();
break;
case SHARE.who: if (this.host === s) this.send(s, [sender], SHARE.on); break;
case SHARE.watch: if (this.host === s) this.offerTo(s, sender).catch(() => this.dropPeer(sender)); break;
case SHARE.leave: if (this.host === s) this.dropPeer(sender); break;
case SHARE.offer: if (msg.text) this.answerTo(s, sender, msg.text); break;
case SHARE.answer:
if (this.host === s && msg.text) this.peers.get(sender)?.setRemoteDescription({ type: 'answer', sdp: msg.text }).catch(() => this.dropPeer(sender));
break;
}
}
// We connected or moved: ask who is sharing here, and tell the new channel about our stream
entered(s: Session): void {
if (!this.supported(s)) return;
const self = s.client!.session!;
s.clearSharing();
if (this.watching?.host === s) this.unwatch();
if (this.host === s) {
this.dropPeers();
s.setSharing(self, true);
this.send(s, this.others(s), SHARE.on);
}
this.send(s, this.others(s), SHARE.who);
}
// Someone else connected, moved or left
userMoved(s: Session, user: User, gone = false): void {
if (!s.client?.self || user.session === s.client.session) return;
if (!gone && this.inMyChannel(s, user.session)) {
if (this.host === s) this.send(s, [user.session], SHARE.on);
return;
}
s.setSharing(user.session, false);
if (this.host === s) this.dropPeer(user.session);
if (this.watching?.host === s && this.watching.session === user.session) this.endWatch();
}
closed(s: Session): void {
if (this.host === s) {
this.dropPeers();
this.stream?.getTracks().forEach(t => t.stop());
this.stream = null;
this.host = null;
}
if (this.watching?.host === s) this.endWatch();
}
}
export const share = new ScreenShare();
+2
View File
@@ -9,6 +9,8 @@ export interface PromptRequest {
initial?: string;
text?: string;
onsubmit: (value: string) => void;
// Closed without confirming
oncancel?: () => void;
}
export interface CertRequest {
+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>