Screen sharing: start dialog, sound, video tiles and a meeting view

- Start dialog with source list, live preview, sound and quality choices
- Sound on Linux through PipeWire: one program or everything except this
  app, fed into a virtual microphone and kept linked as programs come and go;
  system loopback on Windows; stereo Opus
- A sharer's tile shows the picture; clicking it opens a large view with the
  other tiles as a strip. Own stream is previewed in the own tile
- Stream volume for viewers, watch and stop from the profile panel
- Unit tests for the PipeWire link planner; the proxy test no longer writes to
  the root channel other tests listen in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 21:19:29 +02:00
co-authored by Claude Opus 5.5
parent 0cce3c97c2
commit 3b23f69a5f
20 changed files with 744 additions and 185 deletions
+3 -2
View File
@@ -15,7 +15,8 @@
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 ShareDialog from './ui/ShareDialog.svelte';
import { share } from './lib/share.svelte.ts';
import ResizeHandle from './ui/ResizeHandle.svelte';
import { identities } from './lib/identities.svelte.ts';
import { session } from './lib/session.svelte.ts';
@@ -130,7 +131,7 @@
{#if ui.channelDialog && session.status === 'connected'}
{#key ui.channelDialog}<ChannelDialog {...ui.channelDialog} onclose={() => (ui.channelDialog = null)} />{/key}
{/if}
<SharePicker />
{#if share.dialog}<ShareDialog onclose={() => (share.dialog = null)} />{/if}
{#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => { const p = ui.prompt; ui.prompt = null; p?.oncancel?.(); }} />{/if}
<ContextMenu />
+8 -1
View File
@@ -31,8 +31,15 @@ interface NativeApi extends PlatformApi {
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 }>;
// Thumbnails are slower to get and can fail; ask for the names first
screenSources(thumbnails: boolean): Promise<{ sources: ScreenSource[]; chosen: boolean }>;
pickScreenSource(id: string): void;
// portal: the system shows its own source picker (Wayland). audio: how other programs' sound can be captured.
shareInfo(): Promise<{ portal: boolean; audio: 'pipewire' | 'loopback' | 'none'; micLabel: string }>;
shareAudioApps(): Promise<string[]>;
// Linux: route one program, or everything but this app, into a virtual microphone named micLabel
shareAudioStart(target: { kind: 'all' } | { kind: 'app'; name: string }): Promise<void>;
shareAudioStop(): Promise<void>;
open(connId: string, host: string, port: number, identityId?: string): Promise<void>;
send(connId: string, bytes: Uint8Array): void;
close(connId: string): void;
+4
View File
@@ -24,6 +24,8 @@ export interface Settings {
theme: ThemeId;
publicListUrl: string; // where the public server list comes from
arrangement: Arrangement;
shareQuality: 'low' | 'medium' | 'high'; // bitrate and frame rate of our own stream
shareVolume: number; // volume of streams we watch, 0 to 1
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
@@ -54,6 +56,8 @@ const defaults: Settings = {
theme: 'dark',
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0',
arrangement: 'side',
shareQuality: 'medium',
shareVolume: 1,
stunServer: '',
shareNoticeSeen: false,
hints: false
+41 -41
View File
@@ -5,13 +5,21 @@
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 { desktop } 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 const QUALITY = {
low: { label: 'Low (1 Mbit/s, 30 fps)', bitrate: 1_000_000, fps: 30 },
medium: { label: 'Medium (4 Mbit/s, 30 fps)', bitrate: 4_000_000, fps: 30 },
high: { label: 'High (8 Mbit/s, 60 fps)', bitrate: 8_000_000, fps: 60 }
} as const;
// Opus is negotiated as mono speech by default; shared sound is stereo and often music
const stereo = (sdp: string) => sdp.replace(/a=fmtp:(\d+) ([^\r\n]*useinbandfec=1[^\r\n]*)/g,
(line, pt, rest) => rest.includes('stereo=1') ? line : `a=fmtp:${pt} ${rest};stereo=1;sprop-stereo=1;maxaveragebitrate=192000`);
export interface Watching {
host: Session;
@@ -27,8 +35,9 @@ class ScreenShare {
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);
// The server the start dialog is open for
dialog = $state.raw<Session | null>(null);
private usesVirtualMic = false;
private peers = new Map<number, RTCPeerConnection>();
private watchPc: RTCPeerConnection | null = null;
@@ -75,55 +84,50 @@ class ScreenShare {
});
}
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 });
// Opens the dialog where the source, the sound and the quality are chosen
async start(s: Session): Promise<void> {
if (this.stream || this.dialog || !this.supported(s) || !(await this.notice())) return;
this.error = '';
this.dialog = s;
}
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}`;
// The dialog hands over what it captured
go(s: Session, stream: MediaStream, usesVirtualMic: boolean): void {
this.dialog = null;
// Disconnected while the dialog was open
if (!s.client || this.stream) {
stream.getTracks().forEach(t => t.stop());
if (usesVirtualMic) desktop?.shareAudioStop();
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.usesVirtualMic = usesVirtualMic;
this.host = s;
this.stream = stream;
this.send(s, this.others(s), SHARE.on);
s.setSharing(s.client.session!, true);
}
private release(): void {
this.dropPeers();
this.stream?.getTracks().forEach(t => t.stop());
this.stream = null;
this.host = null;
if (this.usesVirtualMic) desktop?.shareAudioStop();
this.usesVirtualMic = false;
}
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;
this.release();
}
private dropPeers(): void {
@@ -195,10 +199,10 @@ class ScreenShare {
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;
for (const enc of params.encodings ?? []) enc.maxBitrate = QUALITY[store.settings.shareQuality].bitrate;
await sender.setParameters(params).catch(() => {});
}
await pc.setLocalDescription(await pc.createOffer());
await pc.setLocalDescription({ type: 'offer', sdp: stereo((await pc.createOffer()).sdp ?? '') });
await this.gathered(pc);
if (this.peers.get(viewer) === pc && pc.localDescription) await this.send(s, [viewer], SHARE.offer, pc.localDescription.sdp);
}
@@ -215,7 +219,7 @@ class ScreenShare {
});
try {
await pc.setRemoteDescription({ type: 'offer', sdp });
await pc.setLocalDescription(await pc.createAnswer());
await pc.setLocalDescription({ type: 'answer', sdp: stereo((await pc.createAnswer()).sdp ?? '') });
await this.gathered(pc);
if (this.watchPc === pc && pc.localDescription) await this.send(s, [sharer], SHARE.answer, pc.localDescription.sdp);
} catch {
@@ -275,12 +279,8 @@ class ScreenShare {
}
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.host === s) this.release();
if (this.dialog === s) this.dialog = null;
if (this.watching?.host === s) this.endWatch();
}
}
+1 -3
View File
@@ -3,7 +3,6 @@
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,8 +27,7 @@
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
</header>
{#if showStage && session.status === 'connected'}<VoiceStage />{/if}
{#if session.status === 'connected'}<ShareView />{/if}
{#if session.status === 'connected'}<VoiceStage always={showStage} />{/if}
{#if session.status === 'idle' && session.disconnectInfo}
{@const d = session.disconnectInfo}
+15
View File
@@ -13,6 +13,8 @@
import { voice } from '../lib/audio/voice.svelte.ts';
import { renderIncoming } from '../lib/html.ts';
import RichEditor from './RichEditor.svelte';
import { share } from '../lib/share.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
let { onnavigate, onclose }: { onnavigate: () => void; onclose: () => void } = $props();
@@ -124,6 +126,16 @@
</div>
</div>
{#if session.sharing[user.session]}
<div class="sharing">
<Icon name="screen" size={16} />
<span>{isSelf ? 'You are sharing your screen' : 'Sharing their screen'}</span>
{#if isSelf}<button class="btn" onclick={() => share.stop()}>Stop sharing</button>
{:else if share.watching?.session === user.session}<button class="btn" onclick={() => share.unwatch()}>Stop watching</button>
{:else}<button class="btn primary" onclick={() => share.watch(sessions.active, user.session)}>Watch</button>{/if}
</div>
{/if}
{#if isSelf}
<div class="actions"><button class="btn" onclick={() => changeNamePrompt(user)}>Change name</button></div>
{/if}
@@ -158,6 +170,9 @@
</aside>
<style>
.sharing { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 8px 10px; font-size: 13px; color: var(--speaking); border: 1px solid var(--line); background: var(--bg-0); }
.sharing span { flex: 1; min-width: 0; color: var(--text); }
.sharing .btn { padding: 4px 10px; font-size: 12px; }
.panel { position: relative; max-width: 100%; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); min-height: 0; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+224
View File
@@ -0,0 +1,224 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Modal from './Modal.svelte';
import { share, QUALITY } from '../lib/share.svelte.ts';
import { desktop, type ScreenSource } from '../lib/native.ts';
import { store } from '../lib/settings.svelte.ts';
// Everything is chosen here, with a preview, before anything is sent: what to show, which
// sound goes with it and the quality. In a browser, and on Wayland, the system's own dialog
// picks the screen or window.
let { onclose }: { onclose: () => void } = $props();
const target = share.dialog!;
let info = $state<{ portal: boolean; audio: 'pipewire' | 'loopback' | 'browser' | 'none'; micLabel: string }>({ portal: true, audio: desktop ? 'none' : 'browser', micLabel: '' });
let sources = $state<ScreenSource[]>([]);
let selected = $state('');
let preview = $state.raw<MediaStream | null>(null);
let apps = $state<string[]>([]);
// '' none, 'all' everything, 'app:<name>' one program
let audio = $state('');
let browserAudio = $state(false);
let busy = $state(false);
let error = $state('');
let handedOver = false;
let video = $state<HTMLVideoElement>();
$effect(() => { if (video) video.srcObject = preview; });
const fps = () => QUALITY[store.settings.shareQuality].fps;
let listing = $state(false);
async function loadSources() {
if (!desktop) return;
listing = true;
try {
sources = (await desktop.screenSources(false)).sources;
} finally {
listing = false;
}
// Pictures follow when they are ready
const pictures = new Map((await desktop.screenSources(true)).sources.map(s => [s.id, s.thumbnail]));
sources = sources.map(s => ({ ...s, thumbnail: pictures.get(s.id) ?? s.thumbnail }));
}
const loadApps = async () => { if (desktop && info.audio === 'pipewire') apps = await desktop.shareAudioApps(); };
if (desktop) {
desktop.shareInfo().then(async i => {
info = i;
loadApps();
if (!i.portal) await loadSources();
});
}
function dropPreview() {
preview?.getTracks().forEach(t => t.stop());
preview = null;
}
async function show(id: string) {
error = '';
dropPreview();
selected = id;
try {
desktop!.pickScreenSource(id);
preview = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: fps() }, audio: false });
} catch (e) {
error = `Could not capture this: ${(e as Error).message}`;
}
}
// The system's or the browser's picker
async function chooseElsewhere() {
error = '';
try {
if (desktop) {
const { sources: picked } = await desktop.screenSources(false);
if (!picked.length) return;
sources = picked;
await show(picked[0].id);
} else {
dropPreview();
preview = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: fps() }, audio: browserAudio });
}
} catch (e) {
if ((e as Error).name !== 'NotAllowedError') error = `Could not capture the screen: ${(e as Error).message}`;
}
}
// The virtual microphone shows up as a recording device a moment after it is created
async function virtualMic(): Promise<MediaStreamTrack> {
const find = async () => (await navigator.mediaDevices.enumerateDevices()).find(d => d.kind === 'audioinput' && d.label.includes(info.micLabel));
let device = await find();
for (let i = 0; i < 20 && !device; i++) {
await new Promise(r => setTimeout(r, 150));
device = await find();
}
if (!device) throw new Error('The audio capture device did not appear.');
const s = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: device.deviceId }, echoCancellation: false, autoGainControl: false, noiseSuppression: false, channelCount: 2 }
});
return s.getAudioTracks()[0];
}
async function start() {
if (!preview) return;
busy = true;
error = '';
let usesVirtualMic = false;
try {
if (audio && info.audio === 'pipewire') {
usesVirtualMic = true;
await desktop!.shareAudioStart(audio === 'all' ? { kind: 'all' } : { kind: 'app', name: audio.slice(4) });
preview.addTrack(await virtualMic());
} else if (audio && info.audio === 'loopback') {
// System sound comes with a capture request; only its audio track is kept
desktop!.pickScreenSource(selected);
const extra = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
extra.getVideoTracks().forEach(t => t.stop());
const track = extra.getAudioTracks()[0];
if (track) preview.addTrack(track);
}
} catch (e) {
if (usesVirtualMic) desktop!.shareAudioStop();
error = `Could not capture the sound: ${(e as Error).message}`;
busy = false;
return;
}
handedOver = true;
share.go(target, preview, usesVirtualMic);
onclose();
}
onDestroy(() => { if (!handedOver) dropPreview(); });
</script>
<Modal title="Share your screen" {onclose} width={760}>
<p class="help">mumh5 users in your channel can watch. Nothing is sent until you press Start sharing.</p>
{#if !desktop && info.audio === 'browser'}
<label class="check"><input type="checkbox" bind:checked={browserAudio} disabled={!!preview} /> Include sound (the browser offers what it can share, usually a tab's sound)</label>
{/if}
{#if desktop && !info.portal}
<div class="row head"><label for="sh-src">Screen or window</label><button class="link" onclick={loadSources}>Refresh</button></div>
<ul class="sources" id="sh-src">
{#each sources as s (s.id)}
<li>
<button class:active={selected === s.id} aria-pressed={selected === s.id} onclick={() => show(s.id)}>
{#if s.thumbnail}<img src={s.thumbnail} alt="" />{:else}<span class="blank"></span>{/if}
<span class="name">{s.name}</span>
</button>
</li>
{:else}
<li class="dim">{listing ? 'Looking for screens and windows...' : 'Nothing found. Try Refresh.'}</li>
{/each}
</ul>
{:else}
<div class="row"><button class="btn" onclick={chooseElsewhere}>{preview ? 'Choose another screen or window...' : 'Choose a screen or window...'}</button></div>
{/if}
<span class="label">Preview</span>
<div class="preview">
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={video} autoplay playsinline muted></video>
{#if !preview}<p>Nothing chosen yet</p>{/if}
</div>
<div class="row two">
{#if desktop}
<div>
<label for="sh-audio">Sound</label>
{#if info.audio === 'pipewire'}
<select id="sh-audio" bind:value={audio} onfocus={loadApps}>
<option value="">No sound</option>
<option value="all">Everything except mumh5</option>
{#each apps as a (a)}<option value={`app:${a}`}>{a}</option>{/each}
</select>
{:else if info.audio === 'loopback'}
<select id="sh-audio" bind:value={audio}>
<option value="">No sound</option>
<option value="all">Whole system (viewers also hear the voice chat again)</option>
</select>
{:else}
<select id="sh-audio" disabled><option>Not available on this system</option></select>
{/if}
</div>
{/if}
<div>
<label for="sh-quality">Quality</label>
<select id="sh-quality" bind:value={store.settings.shareQuality} onchange={() => store.saveSettings()}>
{#each Object.entries(QUALITY) as [id, q] (id)}<option value={id}>{q.label}</option>{/each}
</select>
</div>
</div>
{#if error}<p class="err">{error}</p>{/if}
{#snippet footer()}
<button class="btn" onclick={onclose}>Cancel</button>
<button class="btn primary" disabled={!preview || busy} onclick={start}>{busy ? 'Starting...' : 'Start sharing'}</button>
{/snippet}
</Modal>
<style>
.help { margin: 8px 0 0; font-size: 13px; color: var(--text-dim); }
.row { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
.row.head { justify-content: space-between; margin-top: 0; }
.row.two { align-items: flex-end; gap: 12px; }
.row.two > div { flex: 1; min-width: 0; }
.label { display: block; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); margin: 14px 0 6px; }
.link { color: var(--link); font-size: 13px; margin-top: 8px; }
.sources { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; max-height: 190px; overflow-y: auto; }
.sources button { width: 100%; display: flex; flex-direction: column; gap: 4px; padding: 6px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); }
.sources button:hover { border-color: var(--text-faint); }
.sources button.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
img, .blank { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; background: #000; display: block; }
.name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.preview { position: relative; background: #000; aspect-ratio: 16 / 9; max-height: 240px; margin: 0 auto; width: 100%; }
.preview video { width: 100%; height: 100%; object-fit: contain; display: block; }
.preview p { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; color: #aaa; font-size: 13px; }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 12px; }
.check input { width: auto; }
.dim { color: var(--text-dim); font-size: 13px; }
.err { color: var(--danger); font-size: 13px; margin: 10px 0 0; }
</style>
-35
View File
@@ -1,35 +0,0 @@
<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
@@ -1,60 +0,0 @@
<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>
+140 -10
View File
@@ -7,11 +7,45 @@
import { ui } from '../lib/ui.svelte.ts';
import { userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { share } from '../lib/share.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
// People in your voice channel as tiles. Shown on windows too narrow for the member list
// (always), and on any window while someone in the channel shares a screen: a sharer's tile
// carries the picture, and clicking it puts it on a large stage with the tiles as a strip.
let { always = false }: { always?: boolean } = $props();
// People in your voice channel as tiles, for windows too narrow for the member list
const self = $derived(session.self);
const channel = $derived(self ? session.channel(self.channelId) : undefined);
const people = $derived(self ? session.usersIn(self.channelId) : []);
const here = (s: { server?: { id: string } | null } | null | undefined) => !!s && s.server?.id === session.server?.id;
const watching = $derived(share.watching && here(share.watching.host) ? share.watching : null);
const mine = $derived(share.stream && here(share.host) ? share.stream : null);
const anySharing = $derived(people.some(u => session.sharing[u.session]));
const visible = $derived(!!self && (always || anySharing || !!watching || !!mine));
// The picture a tile can show: our own stream, or the one we are watching
function streamOf(s: number): MediaStream | null {
if (s === self?.session) return mine;
return watching?.session === s ? watching.stream : null;
}
// Tile on the large stage
let focus = $state<number | null>(null);
const focused = $derived(focus != null ? streamOf(focus) : null);
const focusUser = $derived(focus != null ? session.user(focus) : undefined);
// A stream we just started watching goes to the stage; it leaves when the stream ends
let lastWatch: number | null = null;
$effect(() => {
const now = watching?.session ?? null;
if (now !== lastWatch) {
if (now != null) focus = now;
else if (focus === lastWatch) focus = null;
lastWatch = now;
}
});
$effect(() => { if (focus === self?.session && !mine) focus = null; });
let collapsed = $state(load());
function load(): boolean {
@@ -23,10 +57,39 @@
}
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
// The same stream can sit in a tile and on the stage at once
function media(node: HTMLMediaElement, stream: MediaStream | null) {
node.srcObject = stream;
return { update(next: MediaStream | null) { if (node.srcObject !== next) node.srcObject = next; } };
}
// Sound comes from one hidden player, so it plays once however many pictures are shown
let player = $state<HTMLAudioElement>();
const hasSound = $derived(!!watching?.stream?.getAudioTracks().length);
$effect(() => {
if (!player) return;
player.volume = store.settings.shareVolume;
player.muted = store.settings.shareVolume === 0;
});
function tileClick(session_: number) {
if (streamOf(session_)) focus = focus === session_ ? null : session_;
else if (session.sharing[session_] && session_ !== self?.session) share.watch(sessions.active, session_);
else { session.showUser(session_); ui.panelOpen = true; }
}
let frame = $state<HTMLElement>();
function fullscreen() {
if (document.fullscreenElement) document.exitFullscreen();
else frame?.requestFullscreen().catch(() => {});
}
</script>
{#if self}
<section class="stage" aria-label="Voice channel">
{#if share.error}<div class="bar bad" role="alert"><span>{share.error}</span><button class="btn" onclick={() => (share.error = '')}>Dismiss</button></div>{/if}
{#if visible && self}
<section class="stage" class:meeting={!!focused} aria-label="Voice channel">
<header>
<button class="title" onclick={toggle} aria-expanded={!collapsed}>
<svg class="caret" class:collapsed width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
@@ -38,6 +101,10 @@
</span>
{/if}
</button>
{#if mine}
<span class="status" role="status">You are sharing your screen{mine.getAudioTracks().length ? ' with sound' : ''}{share.viewers ? `, ${share.viewers} watching` : ''}</span>
<button class="btn small" onclick={() => share.stop()}>Stop sharing</button>
{/if}
<button class="icon-btn" class:on={self.selfMute} aria-label={self.selfMute ? 'Unmute' : 'Mute'} title={self.selfMute ? 'Unmute' : 'Mute'}
onclick={() => session.setSelfMute(!self.selfMute)}>
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} />
@@ -47,16 +114,52 @@
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} />
</button>
</header>
{#if watching?.stream}<audio bind:this={player} use:media={watching.stream} autoplay></audio>{/if}
{#if focused && focus != null}
<div class="spot" role="region" aria-label="Screen share" bind:this={frame}>
<div class="spot-head">
<Icon name="screen" size={16} />
<span class="spot-who">{focus === self.session ? 'Your screen' : focusUser ? store.displayName(focusUser) : 'Screen share'}</span>
{#if focus !== self.session && hasSound}
<Icon name={store.settings.shareVolume === 0 ? 'headphones-off' : 'headphones'} size={16} />
<input class="vol" type="range" min="0" max="1" step="0.01" bind:value={store.settings.shareVolume} onchange={() => store.saveSettings()}
aria-label="Stream volume" title="Stream volume" />
{/if}
<button class="icon-btn" title="Full screen" aria-label="Full screen" onclick={fullscreen}><Icon name="expand" size={16} /></button>
{#if focus !== self.session}<button class="btn small" onclick={() => share.unwatch()}>Stop watching</button>{/if}
<button class="icon-btn" title="Back to tiles" aria-label="Back to tiles" onclick={() => (focus = null)}><Icon name="x" size={16} /></button>
</div>
<!-- svelte-ignore a11y_media_has_caption -->
<video use:media={focused} autoplay playsinline muted ondblclick={fullscreen}></video>
</div>
{/if}
{#if !collapsed}
<ul class="tiles">
<ul class="tiles" class:strip={!!focused}>
{#each people as u (u.session)}
<li>
<button class="tile" class:talking={talking(u.session)} class:self={u.session === self.session}
onclick={() => { session.showUser(u.session); ui.panelOpen = true; }}
oncontextmenu={e => menu.show(e, userMenu(u))} title={store.displayName(u)}>
<Avatar name={u.name} size={44} speaking={talking(u.session)} />
{@const stream = streamOf(u.session)}
{@const sharing = !!session.sharing[u.session]}
{@const isSelf = u.session === self.session}
<li class:wide={!!stream}>
<button class="tile" class:talking={talking(u.session)} class:self={isSelf} class:video={!!stream} class:focused={focus === u.session}
onclick={() => tileClick(u.session)}
oncontextmenu={e => menu.show(e, userMenu(u))}
title={stream ? (focus === u.session ? 'Back to tiles' : 'Show large') : sharing && !isSelf ? 'Sharing their screen. Click to watch' : store.displayName(u)}
aria-label={stream ? `${focus === u.session ? 'Shrink' : 'Enlarge'} the screen of ${store.displayName(u)}` : sharing && !isSelf ? `Watch the screen of ${store.displayName(u)}` : store.displayName(u)}>
{#if stream}
<!-- svelte-ignore a11y_media_has_caption -->
<video use:media={stream} autoplay playsinline muted></video>
{:else}
<Avatar name={u.name} size={44} speaking={talking(u.session)} />
{/if}
<span class="who">{store.displayName(u)}</span>
{#if u.deaf || u.selfDeaf}
{#if watching?.session === u.session && watching.state !== 'live'}
<span class="note">{watching.state === 'connecting' ? 'Connecting...' : 'Could not connect'}</span>
{/if}
{#if sharing}<span class="badge live"><Icon name="screen" size={13} /></span>
{:else if u.deaf || u.selfDeaf}
<span class="badge" class:admin={u.deaf}><Icon name="headphones-off" size={13} /></span>
{:else if u.mute || u.selfMute || u.suppress}
<span class="badge" class:admin={u.mute || u.suppress}><Icon name="mic-off" size={13} /></span>
@@ -65,6 +168,9 @@
</li>
{/each}
</ul>
{#if watching?.state === 'failed'}
<p class="failed">Could not connect to the stream. You and the other person may both be behind routers that block direct connections; a STUN server in Settings, Voice can help.</p>
{/if}
{/if}
</section>
{/if}
@@ -89,4 +195,28 @@
.tile.self .who { color: var(--text); font-weight: 600; }
.badge { position: absolute; top: 4px; right: 4px; display: inline-flex; color: var(--text-faint); }
.badge.admin { color: var(--danger); }
.badge.live { color: var(--speaking); }
/* Streams */
.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); }
.bar span { flex: 1; min-width: 0; }
.bar.bad { color: var(--danger); }
.btn.small, .bar .btn { padding: 4px 10px; font-size: 12px; flex: none; }
.status { font-size: 12px; color: var(--speaking); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; margin-right: 4px; }
.tiles li.wide { grid-column: span 2; }
.tile.video { padding: 0 0 6px; overflow: hidden; }
.tile.video video { width: 100%; aspect-ratio: 16 / 9; object-fit: contain; background: #000; display: block; }
.tile.focused { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.note { font-size: 11px; color: var(--text-faint); }
.failed { margin: 0; padding: 0 12px 10px; font-size: 12px; color: var(--danger); }
/* Meeting view: the chosen picture large, everyone else in one row below */
.stage.meeting { display: flex; flex-direction: column; max-height: 72%; min-height: 220px; }
.spot { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #000; }
.spot-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 2px 6px 2px 12px; background: var(--bg-0); color: var(--text-dim); font-size: 13px; }
.spot-who { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-weight: 600; }
.spot video { flex: 1; min-height: 0; width: 100%; object-fit: contain; background: #000; display: block; }
.vol { width: 110px; flex: none; padding: 0; }
.tiles.strip { flex: none; display: flex; overflow-x: auto; overflow-y: hidden; padding-top: 8px; max-height: none; }
.tiles.strip li { flex: 0 0 96px; }
.tiles.strip li.wide { flex-basis: 150px; }
</style>