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:
+3
-1
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)]) },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -9,6 +9,8 @@ export interface PromptRequest {
|
||||
initial?: string;
|
||||
text?: string;
|
||||
onsubmit: (value: string) => void;
|
||||
// Closed without confirming
|
||||
oncancel?: () => void;
|
||||
}
|
||||
|
||||
export interface CertRequest {
|
||||
|
||||
@@ -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