Screen sharing #5
+1
-1
@@ -6,7 +6,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`.
|
||||
|
||||
### Added
|
||||
|
||||
- Screen sharing between mumh5 users in a channel (picture only so far): a toolbar button starts it, others click the indicator next to your name to watch. Streams go directly between clients; a STUN server can be set in Settings, Voice.
|
||||
- Screen sharing between mumh5 users in a channel: a dialog to choose the screen or window (with preview), the sound and the quality; sound from one program or the whole system on Linux through PipeWire. Viewers watch in the voice tiles, enlarge a stream like in a meeting and set its volume. Streams go directly between clients; a STUN server can be set in Settings, Voice.
|
||||
- Browser version: `npm run build:web` builds the web app and a small self-hosted proxy that bridges browsers to Mumble servers on an allowlist. Identities are kept in the browser, voice goes through the TCP tunnel. See "Browser version" in the README.
|
||||
- Link previews in chat: title, description and image for web links (up to two per message). By default they are fetched by your f0ckm upload host, so the linked sites never see your IP address. Can be switched to "fetched by this computer" or off in Settings, Chat and files.
|
||||
- Voice tiles on small windows: when the window is too narrow for the member list, the people in your voice channel appear as tiles above the chat, light up while they talk, and keep mute and deafen at hand. The tiles can be collapsed.
|
||||
|
||||
@@ -47,7 +47,7 @@ A reused test server keeps registrations and channels from earlier runs; tests m
|
||||
- `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here.
|
||||
- `src/lib/native.ts`: `desktop` is the Electron preload API or null; `native` is what both platforms provide (identities, certificates), backed by `web.svelte.ts` in the browser build (`isWeb`, vite `--mode web`). Desktop-only features check `desktop`.
|
||||
- `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text.
|
||||
- Screen sharing: `src/core/share-signal.ts` (signals as Mumble plugin data, id `mumh5.share`, compressed and chunked) and `src/lib/share.svelte.ts` (one WebRTC connection per viewer, candidates inside the description, no trickle). The desktop source picker is `share:sources` / `share:pick` in `electron/main.ts`.
|
||||
- Screen sharing: `src/core/share-signal.ts` (signals as Mumble plugin data, id `mumh5.share`, compressed and chunked) and `src/lib/share.svelte.ts` (one WebRTC connection per viewer, candidates inside the description, no trickle). The start dialog is `ShareDialog.svelte`, the tiles and the large view are `VoiceStage.svelte`. Desktop source listing is `share:sources` / `share:pick` in `electron/main.ts`; Linux sound is `electron/pipewire.ts` (a virtual microphone fed with pw-link, never with this app's own playback).
|
||||
- `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
|
||||
|
||||
Voice runs on one server at a time (where you last joined a channel); background servers are auto self-deafened and restored when voice returns.
|
||||
@@ -64,7 +64,7 @@ Voice runs on one server at a time (where you last joined a channel); background
|
||||
|
||||
- The CSP allows media only from `self`, `blob:` and http(s). `data:` audio is blocked silently: play user files through object URLs. Large user files go to IndexedDB (`src/lib/blobstore.ts`), not localStorage.
|
||||
- `window.prompt` does not exist in Electron; use `ui.prompt`.
|
||||
- `desktopCapturer.getSources` can return an empty list on its first calls under X11; main retries. On Wayland the call itself opens the system picker, so it is never repeated.
|
||||
- `desktopCapturer.getSources` can return an empty list on its first calls under X11 and takes seconds on the bare Xvfb test display; main retries and the dialog has Refresh. With `--use-fake-device-for-media-stream` the captured picture is Chromium's test pattern, not the screen. On Wayland the call itself opens the system picker, so it is never repeated.
|
||||
- A drop handler must read derived state before clearing the drag item it derives from.
|
||||
|
||||
## Svelte reactivity pitfalls (all hit before)
|
||||
|
||||
@@ -78,11 +78,12 @@ mumh5 keeps the foundation and replaces the experience.
|
||||
|
||||
### Screen sharing
|
||||
|
||||
- Share a screen or a window with the mumh5 users in your channel; they see an indicator next to your name and click it to watch
|
||||
- No server setup and no extra account: the setup messages travel through the Mumble server, the picture goes directly between the two clients (WebRTC)
|
||||
- Picture only for now, up to 8 viewers. Regular Mumble clients do not see streams
|
||||
- Share a screen or a window with the mumh5 users in your channel. A dialog lets you choose what to show, with a live preview, which sound goes with it and the quality, before anything is sent
|
||||
- Sound: on Linux one program or everything except mumh5 itself (through PipeWire, so viewers do not hear the voice chat twice); on Windows the whole system; in a browser what the browser offers
|
||||
- People in the channel see an indicator next to your name, on your tile and in your profile, and click to watch. Your tile shows the picture; a click makes it large with everyone else in a strip below, like a meeting. Viewers set the stream's volume
|
||||
- No server setup and no extra account: the setup messages travel through the Mumble server, the stream goes directly between the two clients (WebRTC), up to 8 viewers. Regular Mumble clients do not see streams
|
||||
- Direct connections mean sharer and viewer see each other's IP address; mumh5 says so before the first use. Behind most home routers a STUN server (Settings, Voice) is needed; none is contacted unless you enter one
|
||||
- Tested between two desktop instances on one machine; connections across the internet and the browser build are untested
|
||||
- Tested between two desktop instances on one machine with a test picture. Sound capture, real screens, connections across the internet, Windows and the browser build are untested
|
||||
|
||||
### Chat
|
||||
- Messages grouped by author, direct messages, unread badges
|
||||
@@ -262,7 +263,7 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim
|
||||
- System-wide push to talk
|
||||
- Whisper and shout
|
||||
- Rich chat between mumh5 users: replies, reactions, edits, typing indicators
|
||||
- Screen sharing: sound (one application or the whole system, also on Linux through PipeWire), quality settings, and an optional self-hosted relay for many viewers
|
||||
- Screen sharing: several streams at once, and an optional self-hosted relay for many viewers
|
||||
- Release builds for all platforms
|
||||
|
||||
---
|
||||
|
||||
+36
-12
@@ -9,6 +9,7 @@ import { fetchLinkPreview } from './link-preview.ts';
|
||||
import { fetchPublicListWith, pingServer } from './publist.ts';
|
||||
import { openTls } from './tls-transport.ts';
|
||||
import { udpChannel } from './udp-voice.ts';
|
||||
import { ShareAudio, pipewireAvailable, VIRTUAL_MIC_LABEL, type AudioTarget } from './pipewire.ts';
|
||||
|
||||
const devUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
|
||||
@@ -131,20 +132,39 @@ ipcMain.handle('preview:fetch', (_e, url: string) => fetchLinkPreview(String(url
|
||||
// `chosen` tells the renderer not to ask a second time.
|
||||
let screenSources: Electron.DesktopCapturerSource[] = [];
|
||||
let pickedSource: string | null = null;
|
||||
ipcMain.handle('share:sources', async () => {
|
||||
const wayland = process.platform === 'linux' && app.commandLine.getSwitchValue('ozone-platform') !== 'x11' &&
|
||||
const onWayland = () => process.platform === 'linux' && app.commandLine.getSwitchValue('ozone-platform') !== 'x11' &&
|
||||
(process.env.XDG_SESSION_TYPE === 'wayland' || !!process.env.WAYLAND_DISPLAY);
|
||||
const list = () => desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 320, height: 180 } });
|
||||
screenSources = await list();
|
||||
// The X11 capturer can come back empty on its first calls. Not on Wayland, where empty
|
||||
// means the user cancelled the system's picker and asking again would reopen it.
|
||||
for (let i = 0; i < 8 && !screenSources.length && !wayland; i++) {
|
||||
|
||||
// Sound: PipeWire on Linux (one program or everything but this app), system loopback on Windows
|
||||
const shareAudio = new ShareAudio(() => app.getAppMetrics().map(m => m.pid));
|
||||
ipcMain.handle('share:info', async () => ({
|
||||
portal: onWayland(),
|
||||
audio: process.platform === 'win32' ? 'loopback' : (await pipewireAvailable()) ? 'pipewire' : 'none',
|
||||
micLabel: VIRTUAL_MIC_LABEL
|
||||
}));
|
||||
ipcMain.handle('share:audioApps', () => shareAudio.apps().catch(() => []));
|
||||
ipcMain.handle('share:audioStart', (_e, target: AudioTarget) =>
|
||||
shareAudio.start(target?.kind === 'app' ? { kind: 'app', name: String(target.name) } : { kind: 'all' }));
|
||||
ipcMain.handle('share:audioStop', () => shareAudio.stop());
|
||||
// The virtual microphone outlives the process otherwise
|
||||
app.on('will-quit', () => { shareAudio.stop(); });
|
||||
|
||||
// Names first (fast and dependable), thumbnails in a second call: grabbing the pictures can be
|
||||
// slow and, under X11, can come back with nothing.
|
||||
ipcMain.handle('share:sources', async (_e, thumbnails: boolean) => {
|
||||
const wayland = onWayland();
|
||||
const size = thumbnails ? { width: 320, height: 180 } : { width: 0, height: 0 };
|
||||
const list = () => desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: size });
|
||||
let found = await list();
|
||||
// Not on Wayland, where empty means the user cancelled the system's picker and asking again would reopen it
|
||||
for (let i = 0; i < 3 && !found.length && !wayland; i++) {
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
screenSources = await list();
|
||||
found = await list();
|
||||
}
|
||||
if (found.length || !thumbnails) screenSources = found;
|
||||
return {
|
||||
chosen: wayland && screenSources.length === 1,
|
||||
sources: screenSources.map(s => ({ id: s.id, name: s.name, thumbnail: s.thumbnail.isEmpty() ? '' : s.thumbnail.toDataURL() }))
|
||||
chosen: wayland && found.length === 1,
|
||||
sources: found.map(s => ({ id: s.id, name: s.name, thumbnail: s.thumbnail.isEmpty() ? '' : s.thumbnail.toDataURL() }))
|
||||
};
|
||||
});
|
||||
ipcMain.on('share:pick', (_e, id: string) => { pickedSource = String(id); });
|
||||
@@ -224,11 +244,15 @@ app.whenReady().then(async () => {
|
||||
cb({ requestHeaders: details.requestHeaders });
|
||||
});
|
||||
|
||||
session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => {
|
||||
session.defaultSession.setDisplayMediaRequestHandler((request, callback) => {
|
||||
const source = screenSources.find(s => s.id === pickedSource);
|
||||
pickedSource = null;
|
||||
// Without a picked source the request is refused
|
||||
try { callback(source ? { video: source } : {}); } catch { /* refused */ }
|
||||
try {
|
||||
if (!source) callback({});
|
||||
else if (request.audioRequested && process.platform === 'win32') callback({ video: source, audio: 'loopback' });
|
||||
else callback({ video: source });
|
||||
} catch { /* refused */ }
|
||||
});
|
||||
|
||||
// Microphone, camera and screen capture for voice and video; nothing else
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Sound for screen sharing on Linux. Chromium cannot capture what other programs play, so a
|
||||
// virtual microphone is created in PipeWire and the chosen programs' output is linked into it;
|
||||
// the renderer then records that microphone like any other. Works through PipeWire's command
|
||||
// line tools (pw-cli, pw-dump, pw-link, pw-mon), no native module.
|
||||
import { execFile, spawn, type ChildProcess } from 'node:child_process';
|
||||
|
||||
export const VIRTUAL_MIC = 'mumh5-share-audio';
|
||||
export const VIRTUAL_MIC_LABEL = 'mumh5 screen share audio';
|
||||
|
||||
// What to capture: everything except this app's own sound, or one program
|
||||
export type AudioTarget = { kind: 'all' } | { kind: 'app'; name: string };
|
||||
|
||||
interface PwObject { id: number; type: string; info?: { direction?: string; props?: Record<string, any> } }
|
||||
|
||||
function run(cmd: string, args: string[], maxBuffer = 64 * 1024 * 1024): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { maxBuffer, timeout: 5000 }, (err, stdout) => err ? reject(err) : resolve(stdout));
|
||||
});
|
||||
}
|
||||
|
||||
const dump = async (): Promise<PwObject[]> => JSON.parse(await run('pw-dump', []));
|
||||
|
||||
// A playing program: its name and process come from the stream itself or from the client that owns it
|
||||
function describe(node: PwObject, byId: Map<number, PwObject>): { name: string; pid: number | null } {
|
||||
const p = node.info?.props ?? {};
|
||||
const client = byId.get(p['client.id'])?.info?.props ?? {};
|
||||
return {
|
||||
name: String(p['application.name'] ?? client['application.name'] ?? p['node.name'] ?? ''),
|
||||
pid: Number(p['application.process.id'] ?? client['application.process.id']) || null
|
||||
};
|
||||
}
|
||||
|
||||
const outputs = (objects: PwObject[]) =>
|
||||
objects.filter(o => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['media.class'] === 'Stream/Output/Audio');
|
||||
|
||||
// Programs currently playing sound, without this app
|
||||
export function audioApps(objects: PwObject[], ownPids: number[]): string[] {
|
||||
const byId = new Map(objects.map(o => [o.id, o]));
|
||||
const names = new Set<string>();
|
||||
for (const node of outputs(objects)) {
|
||||
const d = describe(node, byId);
|
||||
if (d.name && !(d.pid && ownPids.includes(d.pid))) names.add(d.name);
|
||||
}
|
||||
return [...names].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
// Which output ports to connect to which inputs of the virtual microphone, and which existing
|
||||
// links to remove. This app's own playback is never linked: the people watching are in the same
|
||||
// voice channel and would hear themselves back.
|
||||
export function planLinks(objects: PwObject[], target: AudioTarget, ownPids: number[]): { add: [number, number][]; remove: number[] } {
|
||||
const byId = new Map(objects.map(o => [o.id, o]));
|
||||
const mic = objects.find(o => o.type === 'PipeWire:Interface:Node' && o.info?.props?.['node.name'] === VIRTUAL_MIC);
|
||||
if (!mic) return { add: [], remove: [] };
|
||||
const ports = objects.filter(o => o.type === 'PipeWire:Interface:Port');
|
||||
const micIn = ports.filter(p => p.info?.props?.['node.id'] === mic.id && p.info?.direction === 'input');
|
||||
const input = (channel: string) => micIn.find(p => p.info?.props?.['audio.channel'] === channel)?.id;
|
||||
const left = input('FL'), right = input('FR');
|
||||
if (left == null || right == null) return { add: [], remove: [] };
|
||||
|
||||
const wanted = new Set<string>();
|
||||
for (const node of outputs(objects)) {
|
||||
const d = describe(node, byId);
|
||||
if (d.pid && ownPids.includes(d.pid)) continue;
|
||||
if (target.kind === 'app' && d.name !== target.name) continue;
|
||||
for (const port of ports) {
|
||||
if (port.info?.props?.['node.id'] !== node.id || port.info?.direction !== 'output') continue;
|
||||
const channel = port.info.props['audio.channel'];
|
||||
// Mono programs go to both sides; surround beyond the front pair is left out
|
||||
if (channel === 'FL' || channel === 'MONO') wanted.add(`${port.id}:${left}`);
|
||||
if (channel === 'FR' || channel === 'MONO') wanted.add(`${port.id}:${right}`);
|
||||
}
|
||||
}
|
||||
const remove: number[] = [];
|
||||
for (const link of objects.filter(o => o.type === 'PipeWire:Interface:Link')) {
|
||||
const info = link.info as any;
|
||||
if (info?.['input-node-id'] !== mic.id) continue;
|
||||
const key = `${info['output-port-id']}:${info['input-port-id']}`;
|
||||
if (wanted.has(key)) wanted.delete(key);
|
||||
else remove.push(link.id);
|
||||
}
|
||||
return { add: [...wanted].map(k => k.split(':').map(Number) as [number, number]), remove };
|
||||
}
|
||||
|
||||
// Whether PipeWire and its tools are there
|
||||
export async function pipewireAvailable(): Promise<boolean> {
|
||||
if (process.platform !== 'linux') return false;
|
||||
try { await dump(); await run('pw-link', ['--version']); await run('pw-cli', ['--version']); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
export class ShareAudio {
|
||||
private target: AudioTarget | null = null;
|
||||
private monitor: ChildProcess | null = null;
|
||||
private debounce: ReturnType<typeof setTimeout> | undefined;
|
||||
private busy: Promise<void> = Promise.resolve();
|
||||
private ownPids: () => number[];
|
||||
|
||||
constructor(ownPids: () => number[]) { this.ownPids = ownPids; }
|
||||
|
||||
async apps(): Promise<string[]> {
|
||||
return audioApps(await dump(), this.ownPids());
|
||||
}
|
||||
|
||||
// Creates the microphone, links the target and keeps the links right while programs come and go
|
||||
async start(target: AudioTarget): Promise<void> {
|
||||
this.target = target;
|
||||
if (!(await dump()).some(o => o.info?.props?.['node.name'] === VIRTUAL_MIC)) {
|
||||
await run('pw-cli', ['create-node', 'adapter',
|
||||
`{ factory.name=support.null-audio-sink node.name=${VIRTUAL_MIC} node.description="${VIRTUAL_MIC_LABEL}" media.class=Audio/Source/Virtual object.linger=1 audio.position=[FL,FR] audio.rate=48000 audio.channels=2 }`]);
|
||||
// Give PipeWire a moment to publish the node and its ports
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const objects = await dump();
|
||||
const mic = objects.find(o => o.info?.props?.['node.name'] === VIRTUAL_MIC);
|
||||
if (mic && objects.some(o => o.type === 'PipeWire:Interface:Port' && o.info?.props?.['node.id'] === mic.id)) break;
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
await this.relink();
|
||||
if (!this.monitor) {
|
||||
const proc = spawn('pw-mon', ['--color=never'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
this.monitor = proc;
|
||||
proc.stdout!.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
if (!text.includes('added') && !text.includes('removed')) return;
|
||||
// Starting a program fires many events at once
|
||||
clearTimeout(this.debounce);
|
||||
this.debounce = setTimeout(() => this.relink().catch(() => {}), 400);
|
||||
});
|
||||
proc.on('error', () => { if (this.monitor === proc) this.monitor = null; });
|
||||
proc.on('exit', () => { if (this.monitor === proc) this.monitor = null; });
|
||||
}
|
||||
}
|
||||
|
||||
// One at a time, so two graph changes cannot interleave their link commands
|
||||
private relink(): Promise<void> {
|
||||
const next = this.busy.then(async () => {
|
||||
if (!this.target) return;
|
||||
const { add, remove } = planLinks(await dump(), this.target, this.ownPids());
|
||||
for (const id of remove) await run('pw-link', ['-d', String(id)]).catch(() => {});
|
||||
// "File exists" when a link is already there is fine
|
||||
for (const [out, input] of add) await run('pw-link', [String(out), String(input)]).catch(() => {});
|
||||
});
|
||||
this.busy = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.target = null;
|
||||
clearTimeout(this.debounce);
|
||||
this.monitor?.kill();
|
||||
this.monitor = null;
|
||||
await this.busy;
|
||||
try {
|
||||
for (const o of await dump()) {
|
||||
if (o.type === 'PipeWire:Interface:Node' && o.info?.props?.['node.name'] === VIRTUAL_MIC) await run('pw-cli', ['destroy', String(o.id)]).catch(() => {});
|
||||
}
|
||||
} catch { /* PipeWire gone */ }
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -21,8 +21,12 @@ contextBridge.exposeInMainWorld('mumh5Native', {
|
||||
onContextMenu: (fn: (params: unknown) => void) => { ipcRenderer.on('context-menu', (_e, p) => fn(p)); },
|
||||
editAction: (action: string, arg?: unknown) => ipcRenderer.send('edit:action', action, arg),
|
||||
onTrayAction: (fn: (action: string) => void) => { ipcRenderer.on('tray:action', (_e, a: string) => fn(a)); },
|
||||
screenSources: () => ipcRenderer.invoke('share:sources'),
|
||||
screenSources: (thumbnails: boolean) => ipcRenderer.invoke('share:sources', !!thumbnails),
|
||||
pickScreenSource: (id: string) => ipcRenderer.send('share:pick', id),
|
||||
shareInfo: () => ipcRenderer.invoke('share:info'),
|
||||
shareAudioApps: () => ipcRenderer.invoke('share:audioApps'),
|
||||
shareAudioStart: (target: unknown) => ipcRenderer.invoke('share:audioStart', target),
|
||||
shareAudioStop: () => ipcRenderer.invoke('share:audioStop'),
|
||||
describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders),
|
||||
identities: {
|
||||
list: () => ipcRenderer.invoke('identities:list'),
|
||||
|
||||
+3
-2
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+139
-9
@@ -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)}>
|
||||
{@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>
|
||||
|
||||
@@ -886,6 +886,8 @@ try {
|
||||
// Custom icon from the tile's context menu: square-cropped and shown instead of initials
|
||||
const iconFile = path.join(userData, 'icon.png');
|
||||
writeFileSync(iconFile, testPng(300, 200));
|
||||
// With hints off, the title of whatever the pointer rests on is set aside; move off the tile first
|
||||
await page.mouse.move(700, 400);
|
||||
await page.locator('.rail .tile[title^="Second"]').click({ button: 'right' });
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
await page.getByRole('menuitem', { name: 'Change icon...' }).click();
|
||||
|
||||
+47
-9
@@ -56,45 +56,83 @@ try {
|
||||
await shareBtn.click();
|
||||
await alice.page.getByText(/You see each other's IP address/).waitFor();
|
||||
await alice.page.getByRole('button', { name: 'Continue' }).click();
|
||||
// The dialog: pick a source, see the preview, then start
|
||||
const picker = alice.page.getByRole('dialog', { name: 'Share your screen' });
|
||||
const startBtn = picker.getByRole('button', { name: 'Start sharing' });
|
||||
const pickAndStart = async () => {
|
||||
assert.ok(await startBtn.isDisabled(), 'nothing can start before a source is chosen');
|
||||
// Listing is slow on the bare test display and can come back empty the first time
|
||||
for (let i = 0; i < 6 && !(await picker.locator('.sources button').count()); i++) {
|
||||
await Promise.race([picker.locator('.sources button').first().waitFor({ timeout: 40000 }), picker.getByText('Nothing found').waitFor({ timeout: 40000 })]).catch(() => {});
|
||||
if (await picker.getByText('Nothing found').count()) await picker.getByRole('button', { name: 'Refresh' }).click();
|
||||
}
|
||||
await picker.locator('.sources button').first().click();
|
||||
await alice.page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
|
||||
await startBtn.click();
|
||||
};
|
||||
await picker.getByLabel('Quality').selectOption('low');
|
||||
await pickAndStart();
|
||||
await alice.page.getByText('You are sharing your screen').waitFor();
|
||||
await alice.page.locator('.sidebar .row.user.self').getByTitle('You are sharing your screen').waitFor();
|
||||
console.log('ok: sharing started from the picker');
|
||||
// The sharer's own tile shows what is being sent; clicking it makes it large
|
||||
await alice.page.waitForFunction(() => (document.querySelector('.stage .tile.self video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 15000 });
|
||||
await alice.page.locator('.stage .tile.self').click();
|
||||
const ownView = alice.page.getByRole('region', { name: 'Screen share' });
|
||||
await ownView.getByText('Your screen').waitFor();
|
||||
await ownView.getByRole('button', { name: 'Back to tiles' }).click();
|
||||
await ownView.waitFor({ state: 'detached' });
|
||||
console.log('ok: sharing started from the dialog, own tile shows the stream');
|
||||
|
||||
// Bob connects afterwards and still learns about the stream
|
||||
await join(bob.page, bobName);
|
||||
const watch = bob.page.getByRole("button", { name: `Watch the screen of ${aliceName}`, exact: true });
|
||||
// Next to the name in the channel list, and on the tile above the chat
|
||||
const watch = bob.page.locator('.sidebar').getByRole('button', { name: `Watch the screen of ${aliceName}`, exact: true });
|
||||
await watch.waitFor();
|
||||
const tile = bob.page.locator('.stage .tile', { hasText: aliceName });
|
||||
await tile.waitFor();
|
||||
console.log('ok: a late joiner sees who is sharing');
|
||||
|
||||
await watch.click();
|
||||
await tile.click();
|
||||
await bob.page.getByRole('button', { name: 'Continue' }).click();
|
||||
const view = bob.page.getByRole('region', { name: 'Screen share' });
|
||||
await view.getByText(aliceName).waitFor();
|
||||
await bob.page.waitForFunction(() => (document.querySelector('.watch video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
|
||||
const size = await bob.page.evaluate(() => { const v = document.querySelector('.watch video') as HTMLVideoElement; return [v.videoWidth, v.videoHeight]; });
|
||||
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
|
||||
const size = await bob.page.evaluate(() => { const v = document.querySelector('.spot video') as HTMLVideoElement; return [v.videoWidth, v.videoHeight]; });
|
||||
await alice.page.getByText('You are sharing your screen, 1 watching').waitFor();
|
||||
console.log(`ok: bob receives the picture (${size[0]}x${size[1]}), alice sees one viewer`);
|
||||
|
||||
// Like a meeting: back to tiles keeps the picture in the tile, a click makes it large again
|
||||
await view.getByRole('button', { name: 'Back to tiles' }).click();
|
||||
await view.waitFor({ state: 'detached' });
|
||||
await bob.page.waitForFunction(() => (document.querySelector('.stage .tile.video video') as HTMLVideoElement | null)?.videoWidth! > 0);
|
||||
await tile.click();
|
||||
await view.waitFor();
|
||||
console.log('ok: stream in the tile, large on click');
|
||||
|
||||
// Leaving and coming back
|
||||
await view.getByRole('button', { name: 'Stop watching' }).click();
|
||||
await view.waitFor({ state: 'detached' });
|
||||
await alice.page.getByText('You are sharing your screen', { exact: true }).waitFor();
|
||||
await watch.click();
|
||||
await bob.page.waitForFunction(() => (document.querySelector('.watch video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
|
||||
console.log('ok: stop watching and watch again');
|
||||
// The sharer's profile says so and offers to watch
|
||||
await bob.page.locator('.sidebar .row.user', { hasText: aliceName }).click();
|
||||
const profile = bob.page.locator('.panel .sharing');
|
||||
await profile.getByText('Sharing their screen').waitFor();
|
||||
await profile.getByRole('button', { name: 'Watch' }).click();
|
||||
await profile.getByRole('button', { name: 'Stop watching' }).waitFor();
|
||||
await bob.page.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 20000 });
|
||||
console.log('ok: stop watching, watch again from the profile');
|
||||
|
||||
// Alice stops: the view and the indicator go away
|
||||
await alice.page.locator('.me').getByRole('button', { name: 'Stop sharing your screen' }).click();
|
||||
await view.waitFor({ state: 'detached' });
|
||||
await watch.waitFor({ state: 'detached' });
|
||||
await profile.waitFor({ state: 'detached' });
|
||||
assert.equal(await alice.page.getByText('You are sharing your screen').count(), 0);
|
||||
console.log('ok: stopping ends the stream for viewers');
|
||||
|
||||
// A sharer who disconnects takes the stream with them
|
||||
await shareBtn.click();
|
||||
await picker.locator('.sources button').first().click();
|
||||
await pickAndStart();
|
||||
await watch.waitFor();
|
||||
await alice.page.getByTitle('Disconnect').click();
|
||||
await watch.waitFor({ state: 'detached' });
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { audioApps, planLinks, VIRTUAL_MIC } from '../electron/pipewire.ts';
|
||||
|
||||
// A cut-down pw-dump: the virtual microphone (10), this app (20, process 500), a browser whose
|
||||
// stream has no name of its own (30, named by its client), a mono game (40) and a music player (50)
|
||||
const node = (id: number, props: Record<string, unknown>) => ({ id, type: 'PipeWire:Interface:Node', info: { props } });
|
||||
const client = (id: number, name: string, pid: number) => ({ id, type: 'PipeWire:Interface:Client', info: { props: { 'application.name': name, 'application.process.id': pid } } });
|
||||
const port = (id: number, nodeId: number, direction: string, channel: string) => ({ id, type: 'PipeWire:Interface:Port', info: { direction, props: { 'node.id': nodeId, 'audio.channel': channel } } });
|
||||
const link = (id: number, outPort: number, inPort: number, inNode: number) =>
|
||||
({ id, type: 'PipeWire:Interface:Link', info: { 'output-port-id': outPort, 'input-port-id': inPort, 'input-node-id': inNode } as any });
|
||||
|
||||
const graph = [
|
||||
node(10, { 'node.name': VIRTUAL_MIC, 'media.class': 'Audio/Source/Virtual' }), port(11, 10, 'input', 'FL'), port(12, 10, 'input', 'FR'),
|
||||
port(13, 10, 'output', 'FL'), port(14, 10, 'output', 'FR'),
|
||||
client(2, 'mumh5', 500), node(20, { 'media.class': 'Stream/Output/Audio', 'node.name': 'mumh5', 'client.id': 2 }), port(21, 20, 'output', 'FL'), port(22, 20, 'output', 'FR'),
|
||||
client(3, 'Firefox', 600), node(30, { 'media.class': 'Stream/Output/Audio', 'node.name': 'Firefox', 'client.id': 3 }), port(31, 30, 'output', 'FL'), port(32, 30, 'output', 'FR'),
|
||||
node(40, { 'media.class': 'Stream/Output/Audio', 'application.name': 'Game', 'application.process.id': 700 }), port(41, 40, 'output', 'MONO'),
|
||||
node(50, { 'media.class': 'Stream/Output/Audio', 'application.name': 'Spotify' }), port(51, 50, 'output', 'FL'), port(52, 50, 'output', 'FR'), port(53, 50, 'output', 'LFE'),
|
||||
node(60, { 'media.class': 'Audio/Sink', 'node.name': 'speakers' })
|
||||
];
|
||||
|
||||
test('lists programs that play sound, without this app', () => {
|
||||
assert.deepEqual(audioApps(graph, [500]), ['Firefox', 'Game', 'Spotify']);
|
||||
});
|
||||
|
||||
test('whole system links every program but this app', () => {
|
||||
const { add, remove } = planLinks(graph, { kind: 'all' }, [500]);
|
||||
assert.deepEqual(add.sort(), [[31, 11], [32, 12], [41, 11], [41, 12], [51, 11], [52, 12]].sort());
|
||||
assert.deepEqual(remove, []);
|
||||
});
|
||||
|
||||
test('one program links only that program', () => {
|
||||
assert.deepEqual(planLinks(graph, { kind: 'app', name: 'Firefox' }, [500]).add, [[31, 11], [32, 12]]);
|
||||
});
|
||||
|
||||
test('existing links are kept, stale ones removed', () => {
|
||||
const linked = [...graph, link(90, 31, 11, 10), link(91, 51, 11, 10), link(92, 21, 60, 60)];
|
||||
const { add, remove } = planLinks(linked, { kind: 'app', name: 'Firefox' }, [500]);
|
||||
assert.deepEqual(add, [[32, 12]]);
|
||||
// Spotify's link goes; the link to the speakers is none of our business
|
||||
assert.deepEqual(remove, [91]);
|
||||
});
|
||||
|
||||
test('nothing without the virtual microphone', () => {
|
||||
assert.deepEqual(planLinks(graph.slice(5), { kind: 'all' }, []), { add: [], remove: [] });
|
||||
});
|
||||
+2
-1
@@ -181,7 +181,8 @@ test('two clients talk through the proxy', { skip: !target }, async () => {
|
||||
const a = await connect(`proxy-a-${Date.now() % 100000}`);
|
||||
const b = await connect(`proxy-b-${Date.now() % 100000}`);
|
||||
const got = new Promise<string>(resolve => b.on('text', m => resolve(m.html)));
|
||||
a.sendText({ channels: [0] }, 'hello through the proxy');
|
||||
// A direct message, so other test files listening in the root channel are not disturbed
|
||||
a.sendText({ users: [b.session!] }, 'hello through the proxy');
|
||||
assert.equal(await got, 'hello through the proxy');
|
||||
// No UDP in a browser: voice stays on the TCP tunnel
|
||||
assert.equal(a.udpOk, false);
|
||||
|
||||
Reference in New Issue
Block a user